Zsens Admin 插件菜单"权限继承链"断裂:子菜单绕过父节点 `capability` 导致的越权访问与我的显式阻断方案

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

上周接了个诡异的反馈:某运营角色的同事居然能直接访问插件的"数据库维护"子页面,而他在后台菜单里根本看不到入口。排查一圈发现,问题出在菜单注册的权限继承链上——子菜单没有显式声明 `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 绕路"的?有没有更轻量的做法?

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