插件上线前夜,我靠这份"四维权限验证"清单拦住了一个能绕过 `current_user_can` 的隐藏入口

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 54 浏览 0 回复

上周打包一个会员系统插件,本地测了三天没毛病,预发布环境走流程时,测试同事用"订阅者"账号点进了本该仅管理员可见的"数据导出"页——不是通过菜单,是直接拼 URL 进去的。我后背一凉,意识到之前的上线检查只盯着"有没有菜单",没管"菜单没了路由还在"的死角。

这篇不是讲基础权限判断,是整理我现在的四维验证清单,专门堵那些"单点检查通过、组合起来漏风"的坑。


第一维:路由注册时的"能力断言"(不是只写回调里)

很多人 `register_rest_route` 的 `permission_callback` 写个 `return current_user_can( 'manage_options' );` 就完事。但 WordPress REST 的权限回调有个特性:它只负责"能不能进这道门",不负责"门后还有没有暗门"

我现在强制自己写"双层断言":

// 路由层:粗筛,拦掉明显越权的
'permission_callback' => function( $request ) {
    // 第一层:基础能力
    if ( ! current_user_can( 'my_plugin_export_data' ) ) {
        return new WP_Error(
            'rest_forbidden',
            '无导出权限',
            [ 'status' => 403 ]
        );
    }
    
    // 第二层:上下文校验(防止 capability 被其他插件"借用")
    $requested_user_id = (int) $request->get_param( 'user_id' );
    if ( $requested_user_id && $requested_user_id !== get_current_user_id() ) {
        // 即使是管理员,跨用户导出也需要额外能力
        if ( ! current_user_can( 'my_plugin_export_others_data' ) ) {
            return new WP_Error(
                'rest_forbidden_context',
                '无权导出其他用户数据',
                [ 'status' => 403 ]
            );
        }
    }
    
    return true;
}

关键教训:current_user_can 的参数别用系统内置字符串当"万能钥匙",自定义 capability 才能精确控制。


第二维:菜单注册与路由的"双向绑定"检查

最阴险的 bug:菜单项删了,或者 capability 改了,但路由还留着。用户直接访问 wp-json/my-plugin/v1/export 照样能进。

我的做法是维护一张"菜单-路由对照表",上线前跑脚本扫描:

/**
 * 扫描所有注册路由,检查是否有"无菜单对应"的敏感端点
 */
function my_plugin_audit_orphan_routes() {
    $routes = rest_get_server()->get_routes( 'my-plugin' );
    $menu_slugs = wp_list_pluck( $GLOBALS['menu'], 2 );
    $submenu_slugs = [];
    
    foreach ( $GLOBALS['submenu'] as $parent => $items ) {
        $submenu_slugs = array_merge( $submenu_slugs, wp_list_pluck( $items, 2 ) );
    }
    
    $all_menu_slugs = array_merge( $menu_slugs, $submenu_slugs );
    
    foreach ( $routes as $route => $handlers ) {
        // 只检查写操作路由
        foreach ( $handlers as $handler ) {
            $methods = array_filter( array_keys( $handler['methods'] ) );
            $has_write = array_intersect( [ 'POST', 'PUT', 'DELETE', 'PATCH' ], $methods );
            
            if ( ! $has_write ) {
                continue;
            }
            
            // 检查该路由是否有对应菜单页(通过 option page 或自定义关联)
            $associated_page = $handler['callback'][0]->associated_admin_page ?? null;
            
            if ( $associated_page && ! in_array( $associated_page, $all_menu_slugs, true ) ) {
                error_log( "[AUDIT] 孤儿路由: {$route} -> 关联页面 {$associated_page} 未在菜单注册" );
            }
        }
    }
}

这脚本我放在 CI 里跑,有孤儿路由直接阻断构建。


第三维:配置项的"作用域封印"

插件设置别全塞 options 一张表。我现在区分三类配置,上线前逐项核对:

类型存储位置读写权限典型坑
全局开关site_option / option仅 super admin / admin多站点下 optionsite_option 混用导致站点级覆盖全局
用户偏好user_meta仅本人 + 有 edit_users 的管理员忘记校验 get_user_meta$user_id 参数来源
运行时缓存transient内部使用,不暴露接口transient 名被猜测导致缓存污染(加随机前缀)

一个具体踩坑:update_site_option 在多站点里,如果当前不是 is_network_admin() 页面,有些钩子不会触发,导致你以为保存成功了实际写到了站点级。我现在强制在保存前校验:

function my_plugin_save_global_config( $key, $value ) {
    if ( is_multisite() && ! is_network_admin() ) {
        // 全局配置必须在网络后台操作
        return new WP_Error( 'wrong_context', '全局配置请前往网络管理后台' );
    }
    
    // 额外:校验 key 是否在白名单
    $allowed = [ 'api_endpoint', 'rate_limit', 'feature_flag_x' ];
    if ( ! in_array( $key, $allowed, true ) ) {
        return new WP_Error( 'invalid_key', '配置项不在允许列表' );
    }
    
    return update_site_option( "my_plugin_{$key}", $value );
}

第四维:钩子里的"时序陷阱"——权限在变化

WordPress 的 current_user_can 不是静态的。有些插件会在 init 之后动态添加 capability,有些会在 wp_loaded 移除。如果你的路由权限回调跑在 rest_api_init(优先级 10),而某个角色定义插件在优先级 11 才注入 capability,就会出现检查时没权限、实际能操作的竞态。

我的兜底方案:在执行点再验一次,别只在入口验。

class My_Plugin_Export_Controller {
    
    public function handle_export( $request ) {
        // 入口已验过,但这里再验一次"执行时权限"
        if ( ! current_user_can( 'my_plugin_export_data' ) ) {
            return new WP_Error( 'forbidden_runtime', '权限已变更', [ 'status' => 403 ] );
        }
        
        // 敏感操作前:记录审计日志
        do_action( 'my_plugin_audit_log', [
            'action'   => 'export_initiated',
            'user_id'  => get_current_user_id(),
            'ip'       => $request->get_header( 'x_forwarded_for' ) ?? $_SERVER['REMOTE_ADDR'],
            'params'   => $request->get_params(),
            'time'     => gmdate( 'c' ),
        ] );
        
        // ... 实际导出逻辑
    }
}

我的纸质清单(打印贴显示器旁边)

最后分享我现在的实体 checklist,上线前逐条打勾:

  • [ ] 所有 register_rest_route 都有 permission_callback,且不是返回 true 的占位符
  • [ ] 自定义 capability 已注册(map_meta_cap 或角色安装时注入),没用系统内置字符串凑合
  • [ ] 菜单 capability 与路由权限回调使用同一字符串常量,不是硬编码两份
  • [ ] 子菜单 capability 严格大于等于父菜单(WordPress 不拦你,但空白页很丢人)
  • [ ] 多站点环境:区分了 option / site_option / user_meta / blog_option
  • [ ] 配置保存接口有key 白名单
评论0
回复 · 0
还没有回复
微信客服 微信客服