后台菜单在测试环境明明正常,上线后普通用户却能看到"系统设置":我漏掉的 `add_menu_page` position 参数与权限回调的"静默失效"机制

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

上周把一个新插件丢到预发布环境,运营在群里甩了张截图:一个只配了 `editor` 角色的测试账号,左侧菜单里赫然躺着"数据迁移"和"日志审计"。我本地用管理员账号测了八百遍,完全没复现。

最后定位到两个坑,都是 add_menu_page 的"静默宽容"惹的祸——它不会报错,只是把你漏掉的东西默默补成默认值,而默认值往往是 manage_options

坑一:position 参数被"挤占"后的权限继承

先看这段看似无害的代码:

add_menu_page(
    '数据迁移',
    '数据迁移',
    'manage_options',          // ← 权限
    'my-migration',
    'my_migration_page',
    'dashicons-migrate',
    3                          // ← position
);

问题出在 position = 3。WordPress 后台菜单的 position 不是"排序权重",是绝对坐标占位。3 这个位置默认是 edit.php(文章菜单)。如果另一个插件或主题也注册了 3,或者你多次启用时位置冲突,WordPress 不会报错,而是按注册时序后覆盖前

更隐蔽的是:如果覆盖发生时权限参数不一致,子菜单的权限检测会向上继承最后被写入的那个父级权限。我本地只有一个插件,线上有另一个运营插件也占用了 3,结果它的 read 权限把我的 manage_options 顶掉了,菜单变成所有人可见。

现在我的 position 策略改成带小数偏移,避开整数雷区:

// 59.3 = 设置菜单(60) 前面一点,又避开 59 的插件冲突
add_menu_page( ..., 59.3 );

// 或者用字符串前缀确保唯一性,虽然文档没写但实测有效
add_menu_page( ..., '59.3_my_plugin' );

坑二:权限回调的"假阴性"与 capability 映射

另一个菜单我写了自定义 capability:

add_menu_page(
    '日志审计',
    '日志审计',
    'view_audit_log',          // ← 自定义 capability
    'my-audit',
    'my_audit_page'
);

然后我在 admin_init 里用 current_user_can('view_audit_log') 做页面内二次校验。本地管理员一切正常,线上编辑器账号——菜单没了,但直接访问 /wp-admin/admin.php?page=my-audit 居然能进。

原因是:WordPress 的菜单权限检测只在渲染菜单时执行一次,页面本身的访问并不再次校验 add_menu_page 里的 capability。我的自定义 capability 没有通过 map_meta_cap 映射到实际权限,导致 current_user_can 返回 true(未映射的 cap 默认对所有人 true,这是历史遗留行为)。

修正方案,必须挂 map_meta_cap

add_filter( 'map_meta_cap', function( $caps, $cap, $user_id, $args ) {
    if ( 'view_audit_log' === $cap ) {
        $caps = [ 'manage_options' ];  // 实际映射到管理员
    }
    return $caps;
}, 10, 4 );

我的上线前 Checklist(菜单/权限专项)

从这次事件后,我拆了一张单独的菜单权限检查表,不跟通用的混在一起:

注册阶段

  • [ ] 所有 add_menu_page / add_submenu_page 的 position 用 xx.y 或字符串格式,避开整数冲突
  • [ ] 自定义 capability 必须配套 map_meta_cap 过滤器,禁止裸奔
  • [ ] 子菜单的 slug 与父菜单做前缀关联检查,防止 menu_page_url 同名吞并

校验阶段

  • [ ] 用 wp_get_current_user() 切换到 subscriber / editor / author 三种角色分别扫菜单
  • [ ] 直接构造 URL 访问 admin.php?page=xxx,绕开菜单渲染做权限穿透测试
  • [ ] 检查 $_REQUEST['page'] 与当前 capability 的匹配,防止 A 菜单配 B 权限的"交叉污染"

配置阶段

  • [ ] 如果菜单显示依赖插件设置项,确认 get_option 的默认值在首次激活时已写入,防止 false 导致的意外分支
  • [ ] 多站点环境额外测一次 switch_to_blog 后的菜单隔离性

一个快速自检的 snippet

丢进临时 mu-plugin,访问任意后台页看输出:

add_action( 'admin_menu', function() {
    global $menu;
    $current_user = wp_get_current_user();
    error_log( "User: {$current_user->roles[0]}, Menus: " . json_encode( array_column( $menu, 2 ) ) );
}, 999 );

看日志里角色与菜单 slug 的对应关系,比肉眼扫界面靠谱得多。

你们有遇到过菜单"时有时无"、或者权限在本地线上表现不一致的情况吗?我怀疑还有 $_wp_submenu_nopriv 这个内部变量的坑没踩完。

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