子菜单 `slug` 撞车导致父级整页 403:我排查权限节点时才发现 WordPress 的 `menu_page_url` 有套"同名吞并"暗规则

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

上周给 Zsens Admin 加了个"日志审计"子模块,后台菜单结构大概是:

// 父级
add_menu_page(
    '审计中心',
    '审计中心',
    'manage_audit',
    'zsens-audit',
    [$this, 'render_dashboard'],
    'dashicons-visibility',
    30
);

// 子级 1:操作日志
add_submenu_page(
    'zsens-audit',
    '操作日志',
    '操作日志',
    'view_audit_logs',
    'zsens-audit-logs',
    [$this, 'render_logs']
);

// 子级 2:登录审计(新加的)
add_submenu_page(
    'zsens-audit',
    '登录审计',
    '登录审计',
    'view_login_audit',
    'zsens-audit-logs',  // ← 手滑复制了上面的 slug
    [$this, 'render_login_audit']
);

结果诡异的事情发生了:拥有 view_login_audit 权限的角色点进"登录审计",直接 403;更离谱的是,连原本能正常访问"操作日志"的角色,现在也只能看到"登录审计"的内容,原来的日志页彻底消失。

我第一反应是 current_user_can 判断写岔了,在 render_login_audit 里加了一堆 var_dump,发现请求根本进不到回调里,403 是 WordPress 在分发阶段就拦下来的。

wp-admin/includes/plugin.php 里的 add_submenu_page 实现,才看到这段"沉默的暴力":

$submenu[$parent_slug][] = array( $menu_title, $capability, $menu_slug, $page_title );

WordPress 对子菜单用的是追加到数组,而后续 menu_page_url('zsens-audit-logs') 或者权限校验时,get_plugin_page_hook 内部会走到 $admin_page_hooks[$plugin_page] —— 这里的 $plugin_page 就是 slug,后注册的同名 slug 会直接覆盖 hook 映射。更坑的是 user_can_access_admin_page 这个函数,它从 $_parent_pages[$page_hook] 里找父级,然后拿最后注册的那个子菜单的 capability 来做权限判断。

也就是说,我的两个子菜单虽然挂在同一个父级下,但第二个因为 slug 重复,把第一个的 hook 注册给"借尸还魂"了,而权限校验时用的是后注册的 view_login_audit。所以:

  • 只有 view_login_audit 的角色 → 能进,但看到的是登录审计内容
  • 只有 view_audit_logs 的角色 → 403,因为校验时匹配的是后注册的 capability
  • 两个权限都有的角色 → 永远只能看到登录审计,操作日志的回调被彻底覆盖

而且 WordPress 全程没报错、没警告,WP_DEBUG 开满也是一片寂静。这种"同名吞并"在 add_menu_pageadd_submenu_page 之间也会发生——如果你不小心让顶级菜单的 slug 和某个子菜单撞了,路由分发会直接乱套。

我现在给团队加了两条硬性规范:

1. slug 命名空间隔离

不再手写裸字符串,统一用插件前缀 + 模块 + 动作的三段式:

private function make_slug(string $section, string $action): string {
    return sprintf('zsens-%s-%s', $section, $action);
}
// 生成 zsens-audit-logs-view, zsens-audit-login-view

2. 注册时的防御性断言

admin_menu 钩子末尾加一层自检:

add_action('admin_menu', function () {
    global $submenu;
    $slugs = [];
    foreach ($submenu['zsens-audit'] ?? [] as $item) {
        if (in_array($item[2], $slugs, true)) {
            trigger_error("Duplicate submenu slug detected: {$item[2]}", E_USER_WARNING);
        }
        $slugs[] = $item[2];
    }
}, PHP_INT_MAX);

3. 权限节点声明和 slug 解耦文档

之前我的权限节点定义和 slug 注册散落在两个文件,现在强制要求同一个模块的 capabilityslug回调 在同一个数组里声明,再统一遍历注册:

protected array $audit_pages = [
    [
        'slug'     => 'zsens-audit-logs-view',
        'cap'      => 'view_audit_logs',
        'callback' => 'render_logs',
        'title'    => '操作日志',
    ],
    [
        'slug'     => 'zsens-audit-login-view',
        'cap'      => 'view_login_audit',
        'callback' => 'render_login_audit',
        'title'    => '登录审计',
    ],
];

这样至少能保证"看一眼就能发现 slug 有没有撞"。

最后提一个更隐蔽的坑:如果你用 remove_submenu_page 清理菜单,它返回的是被移除的整个子菜单数组项,但并不会清理 $admin_page_hooks 里的映射。如果你移除后又用相同 slug 重新注册,残留的 hook 指向会导致 screen_base 判断异常,get_current_screen 返回的对象属性全是错的。

有踩过类似坑的老哥吗?或者你们有没有更狠的静态检测手段,能在 CI 阶段就把 slug 冲突扫出来?

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