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

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

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

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

---

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

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

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

```php // 路由层:粗筛,拦掉明显越权的 '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 照样能进。

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

```php /** * 扫描所有注册路由,检查是否有"无菜单对应"的敏感端点 */ 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() 页面,有些钩子不会触发,导致你以为保存成功了实际写到了站点级。我现在强制在保存前校验:

```php 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,就会出现检查时没权限、实际能操作的竞态。

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

```php 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
还没有回复
微信客服 微信客服