插件上线前最后一道闸:我是如何用"权限矩阵表"堵住那个让订阅者能删全站数据的 `delete_option` 漏洞的

插件开发 20 浏览 0 回复 返回上级

上周帮朋友审计一个即将上线的会员插件,在本地一切正常,测试账号也"看起来"没问题。结果我顺手用 `delete_option('site_config')` 试了下——订阅者角色直接干掉了核心配置,后台当场变砖。

复盘发现是 register_setting 的权限参数写串行了,但 WordPress 不会报错,只是"温柔地"允许了越权操作。这让我意识到,零散地检查每个 add_menu_page 的 capability 不够,得搞一张权限矩阵表做系统性拦截。

我的矩阵表长这样

不是文档,是代码里的白名单校验层

// 定义每个配置项的最低角色要求
private $cap_matrix = [
    'api_key'           => 'manage_options',   // 仅管理员
    'webhook_url'       => 'manage_options',
    'default_points'    => 'edit_posts',        // 编辑及以上
    'signup_bonus'      => 'edit_posts',
    'user_can_export'   => false,               // 禁止任何角色通过 REST 修改
];

public function sanitize_option( $value, $option ) {
    $required = $this->cap_matrix[ $option ] ?? 'manage_options';
    
    // 显式 false = 任何请求都拒绝
    if ( false === $required ) {
        add_settings_error( ... );
        return get_option( $option ); // 回滚原值
    }
    
    if ( ! current_user_can( $required ) ) {
        // 记录审计日志,同时返回旧值
        do_action( 'myplugin_unauthorized_option_attempt', $option, wp_get_current_user() );
        return get_option( $option );
    }
    
    return $this->run_type_sanitize( $value, $option );
}

关键点:sanitize_callback最后一道物理闸口,即使前端 JS、REST 权限、菜单隐藏全崩了,这里还能兜底。

路由注册常被漏掉的"暗面"

REST 路由的 permission_callback 很多人写了,但容易忽略批量端点的差异化权限。比如这个:

register_rest_route( 'myplugin/v1', '/users/(?P<id>\d+)/points', [
    'methods'             => 'POST',
    'callback'            => [ $this, 'update_points' ],
    'permission_callback' => '__return_true', // 为了"方便测试"临时写的,上线忘了改!!
] );

我的做法:上线前 grep 全目录搜 __return_true 和空 permission_callback,零容忍。另外给每个路由加权限注释标签,方便矩阵表交叉核对:

/**
 * @capability edit_users
 * @sensitive  true (涉及资金字段)
 */
public function update_points( $request ) { ... }

菜单与路由的"影子对齐"

遇到过菜单用了 manage_options,但对应 AJAX action 只判了 is_user_logged_in()。用户看不到菜单,却能直接 curl 触发接口。

现在我的 Checklist 里有这条:每个后台功能必须同时检查菜单 capability、路由 permission_callback、以及直接方法调用的 current_user_can 三重一致。不一致就是漏洞。

配置项的"自杀开关"检测

有些配置项本身危险,比如允许关闭插件核心功能、清空数据表。我在矩阵表里给这类项加了 destructive 标记,保存前弹二次确认,且要求当前会话有 fresh nonce(非页面加载时的那个,是单独申请的短时效 token):

if ( ! empty( $this->destructive_flags[ $option ] ) ) {
    check_ajax_referer( 'myplugin_destructive_' . $option, 'destructive_nonce' );
    // 同时发邮件通知管理员
    wp_mail( get_option('admin_email'), '危险配置变更', ... );
}

我的实际 Checklist(精简版)

贴在工位上的,每次发布前打勾:

  1. □ grep __return_true / 空 permission_callback / 缺失 current_user_can
  2. □ 矩阵表覆盖所有 register_settingadd_optionupdate_option 入口
  3. □ 菜单 slug 与路由 namespace 无冲突(曾遇 slug=tools 吞掉 WP 原生工具页)
  4. □ 多站点下切换 switch_to_blog 测试配置隔离性
  5. □ 用 WP_DEBUG + Query Monitor 确认无越权查询漏出敏感字段
  6. □ 角色插件(如 User Role Editor)创建"纯订阅者"账号走完整功能路径
  7. □ 破坏性操作有审计日志 + 邮件通知 + 二次 nonce

第 6 条救过我两次:有些功能依赖"编辑"才有的 meta box,但订阅者通过直接 URL 访问 post.php 时,插件没校验就暴露了操作入口。

你们上线前有没有类似的"习惯性漏网之鱼"?我目前最头疼的是第三方扩展包可能绕过我的矩阵表,正在想怎么给 apply_filters 的回调也挂权限钩子……

评论0
回复 · 0
还没有回复
微信客服 微信客服