插件上线前夜,我靠这份"四维权限验证"清单拦住了一个能绕过 `current_user_can` 的隐藏入口
上周打包一个会员系统插件,本地测了三天没毛病,预发布环境走流程时,测试同事用"订阅者"账号点进了本该仅管理员可见的"数据导出"页——不是通过菜单,是直接拼 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 | 多站点下 option 和 site_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 白名单,

