菜单项 `href` 留空时 WordPress 不会报错,但会在 `$_REQUEST['page']` 匹配阶段把权限检查"短路"掉

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

上周给内部 CRM 插件加"数据看板"入口,图省事把父菜单的 href 属性写成了空字符串——本意是做个纯分组标题,子菜单再各自跳转。结果测试账号(角色 custom_sales)点开子菜单里的"客户列表"正常,但直接在地址栏输 /wp-admin/admin.php?page=crm_dashboard 居然能进父级页面,虽然页面是空的,可顶部标题、左侧高亮全在,权限节点 view_crm_reports 跟没设一样。

跟了一下 wp-admin/includes/menu.php 里的逻辑,发现 WordPress 处理菜单匹配时有个坑:

// 简化后的核心逻辑,约在第 200 行附近
if ( ! empty( $parent_file ) ) {
    // 尝试从 $_REQUEST['page'] 反查 menu slug
    // 但如果 $menu[$hookname][2] 是空字符串,这里会提前命中"任意 page 参数"
}

更隐蔽的是 user_has_access 的检查顺序。WordPress 先拿 $_REQUEST['page']$submenu 里找匹配,找不到再回退到 $menu。父菜单的 slug 如果作为"分组锚点"存在,但 href 为空导致它没有独立可访问的 URL,权限回调 current_user_can('view_crm_reports') 实际上只在渲染左侧栏时执行了一次——用于决定显不显示这个菜单项,而不是拦截 HTTP 请求。

我的原始代码长这样:

add_menu_page(
    'CRM 中心',           // page_title
    'CRM',                // menu_title
    'view_crm_reports',   // capability —— 这里其实成了"显示门槛"而非"访问门槛"
    'crm_dashboard',      // menu_slug
    '',                   // function —— 空!没回调
    'dashicons-chart-area',
    30
);

子菜单正常注册了,但父菜单本身成了"幽灵入口":左侧栏里点不了(没 href),直接输 URL 能进(因为 admin.php?page=crm_dashboard 这个路由存在,只是没内容)。更糟的是,如果我在父菜单回调里补一个 wp_die('无权访问'),那左侧栏的父级标题会整组消失——因为 WordPress 渲染菜单时也会调那个回调来判断"有没有内容可显示"。

现在的解法是给父菜单一个"占位回调",里面只做权限拦截和重定向:

add_menu_page(
    'CRM 中心',
    'CRM',
    'view_crm_reports', // 显示门槛
    'crm_dashboard',
    '__crm_parent_gatekeeper', // 不再留空
    'dashicons-chart-area',
    30
);

function __crm_parent_gatekeeper() {
    // 二次校验:即使菜单显示了,直接访问也要拦
    if ( ! current_user_can( 'view_crm_reports' ) ) {
        wp_die( __( '需要 view_crm_reports 权限' ) );
    }
    
    // 父菜单不需要独立页面,把用户分发到默认子菜单
    $first_sub = menu_page_url( 'crm_customer_list', false );
    wp_safe_redirect( $first_sub );
    exit;
}

但这里又踩了一个子菜单 slug 的坑:menu_page_url() 在子菜单里查找时,如果 $submenu['crm_dashboard'] 数组的第一个元素权限比父菜单宽松,WordPress 不会帮你过滤——它只返回第一个注册的子菜单 URL,不管当前用户能不能进。所以我加了个显式遍历:

function __crm_parent_gatekeeper() {
    global $submenu;
    
    if ( ! current_user_can( 'view_crm_reports' ) ) {
        wp_die( '无权访问' );
    }
    
    $target = '';
    if ( isset( $submenu['crm_dashboard'] ) ) {
        foreach ( $submenu['crm_dashboard'] as $item ) {
            // $item[1] 是 capability
            if ( current_user_can( $item[1] ) ) {
                $target = menu_page_url( $item[2], false );
                break;
            }
        }
    }
    
    wp_safe_redirect( $target ?: admin_url() );
    exit;
}

另外发现个冷知识:add_menu_page$position 参数如果和现有菜单冲突,WordPress 会静默覆盖,但不会合并 $submenu 数组。我之前把 position 设成 26.5(想在评论和外观之间),结果和另一个插件撞了,父菜单显示的是我的标题,子菜单里却混进了对方的条目——因为两个插件用了同一个顶层 slug 的哈希前缀。后来改成字符串 '26.94421' 才避开整数碰撞。

最后提一个权限节点的声明细节:自定义 capability 不要只在 add_menu_page 里用字符串硬编码,要在插件激活时写进角色里,否则超级管理员能看到,但新创建的角色(通过第三方角色编辑器)不会自动继承。我的习惯是单独放一个 capability-map.php,激活时遍历:

function crm_activate_caps() {
    $caps = array(
        'view_crm_reports'   => array( 'administrator', 'crm_manager' ),
        'edit_crm_customers' => array( 'administrator', 'crm_manager', 'crm_sales' ),
        'delete_crm_orders'  => array( 'administrator' ),
    );
    
    foreach ( $caps as $cap => $roles ) {
        foreach ( $roles as $role_name ) {
            $role = get_role( $role_name );
            if ( $role && ! $role->has_cap( $cap ) ) {
                $role->add_cap( $cap );
            }
        }
    }
}

但这里有个时序问题:如果用户在插件激活后、再安装角色编辑器并新建角色,这些 capability 不会自动追加。目前我的妥协方案是在 admin_init 里做"缺失补录",但加了 wp_cache_get('crm_caps_synced') 防止每次请求都查库。有没有更干净的做法?

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