Zsens Admin 插件菜单"权限继承链"断裂:子菜单绕过父节点 `capability` 导致的越权访问与我的显式阻断方案
上周接了个诡异的反馈:某运营角色的同事居然能直接访问插件的"数据库维护"子页面,而他在后台菜单里根本看不到入口。排查一圈发现,问题出在菜单注册的权限继承链上——子菜单没有显式声明 `capability` 时,行为跟我想的完全不一样。
先贴一下我原来的注册代码(有坑版)
// 父菜单:需要 manage_options
add_menu_page(
'Zsens 控制台',
'Zsens',
'manage_options', // ← 这里
'zsens-admin',
[$this, 'renderDashboard'],
'dashicons-admin-generic',
30
);
// 子菜单:我以为它会"继承"父菜单权限
add_submenu_page(
'zsens-admin',
'数据库维护',
'数据库维护',
'manage_options', // ← 实际上这里我漏了,填了 zsens_db_maintain
'zsens-db-maintain',
[$this, 'renderDbMaintain']
);
// 另一个子菜单:权限更松,给运营
add_submenu_page(
'zsens-admin',
'内容审核',
'内容审核',
'edit_others_posts', // ← 运营角色有这个
'zsens-content-review',
[$this, 'renderContentReview']
);
看起来没问题对吧?但问题出在路由直接访问的场景。运营角色(`edit_others_posts`)如果知道 URL,直接敲 `wp-admin/admin.php?page=zsens-db-maintain`,居然能进去。
根本原因是 `page` 参数匹配逻辑
WordPress 处理 `admin.php?page=xxx` 时,会去找注册时绑定的 `capability`,但这里有个细节:如果同个 `page` slug 被多次注册,或者权限检查链路里有"漏网之鱼",它不会自动回溯到父菜单的权限。更坑的是,有些主题或插件会在 `admin_menu` 钩子后期做权限"降级"或"重映射",把局面搅得更乱。
我实际遇到的场景是:另一个插件在 `user_has_cap` 过滤里给 `edit_others_posts` 动态附加了 `manage_options` 的某些子集,导致权限判断被污染。
我的修复:三层显式阻断
第一层:注册时强制"权限收紧"
/**
* 子菜单权限必须 getParentCapability($parentSlug); // 自己维护映射表
// 如果子菜单权限比父菜单宽,直接抛异常,开发期就拦住
if (!$this->capabilityImplies($capability, $parentCap)) {
throw new \InvalidArgumentException(
"子菜单 [{$menuSlug}] 权限 [{$capability}] 宽于父菜单 [{$parentSlug}] 的 [{$parentCap}]"
);
}
add_submenu_page($parentSlug, $pageTitle, $menuTitle, $capability, $menuSlug, $callback);
}
这里的 `capabilityImplies` 不是简单字符串比较,而是基于 WP 的权限层级做映射判断(`manage_options` > `edit_others_posts` > `edit_posts` 这种)。
第二层:路由入口加"二次门禁"
public function renderDbMaintain(): void
{
// 显式再验一次,不依赖菜单注册时的"一次性检查"
if (!current_user_can('zsens_db_maintain')) {
wp_die(
__('权限不足。如需访问,请联系管理员授予 Zsens 数据库维护权限。', 'zsens-admin'),
403
);
}
// 实际渲染...
}
注意这里我用的是自定义 capability `zsens_db_maintain`,而不是直接用 `manage_options`。这样粒度更细,也方便角色插件做映射。
第三层:URL 层面的"无菜单即无路由"
add_action('admin_init', function (): void {
if (!is_admin() || !isset($_GET['page'])) {
return;
}
$page = sanitize_key($_GET['page']);
// 只处理我的插件页面
if (strpos($page, 'zsens-') !== 0) {
return;
}
// 检查这个 page 是否在当前用户的菜单里可见
if (!$this->isPageInUserMenu($page)) {
wp_safe_redirect(admin_url('admin.php?page=zsens-admin&error=menu_not_visible'));
exit;
}
});
`isPageInUserMenu` 的实现是:遍历 `global $menu` 和 `global $submenu`,按当前用户的 `capability` 做过滤,看目标 page 是否在可见集合里。这样即使权限过滤被其他插件篡改,菜单的"视觉可见性"成了最后一道防线。
一个容易踩的坑:`add_submenu_page` 的返回值
很多人没注意 `add_submenu_page` 返回的是 hook suffix,但这个 suffix 在权限不匹配时可能是 `false`。我之前拿这个 suffix 去绑 `load-{$suffix}` 做资源加载,结果权限不够时 `false` 转成字符串成了 `load-`,跟其他插件冲突了。
// 错误示范:$suffix 可能为 false
$suffix = add_submenu_page(...);
add_action("load-{$suffix}", [$this, 'enqueueAssets']); // 危险!
// 正确做法
$suffix = add_submenu_page(...);
if ($suffix) {
add_action("load-{$suffix}", [$this, 'enqueueAssets']);
}
最后:我的菜单注册现在长这样
$registry = new MenuRegistry();
$registry->addParent('zsens-admin', 'Zsens', 'manage_options', [$this, 'renderDashboard']);
$registry->addChild(
'zsens-admin',
'zsens-db-maintain',
'数据库维护',
'zsens_db_maintain', // 自定义 cap,由管理员显式分配
[$this, 'renderDbMaintain']
)->requireExplicitGrant(); // 标记为"必须显式授权,不继承任何角色默认"
$registry->addChild(
'zsens-admin',
'zsens-content-review',
'内容审核',
'edit_others_posts',
[$this, 'renderContentReview']
);
`requireExplicitGrant` 会在激活时自动创建这个 capability,但不绑定到任何角色,必须由管理员手动勾。这样彻底杜绝了"默认角色 unexpectedly 能进敏感页面"的问题。
你们菜单权限这块是怎么防"直接 URL 绕路"的?有没有更轻量的做法?

