发版前夜,我在权限检查里埋了颗"定时炸弹":超级管理员能进,自定义角色却404

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

上周打包一个后台管理插件,本地用 admin 账号测得飞起,上线第二天客户反馈:"我们编辑部的'内容运营'角色点进去直接白屏"。一查,current_user_can( 'manage_options' ) 把自定义角色挡得死死的——这角色明明配了 edit_others_postspublish_pages,就是没给 manage_options

从那以后我养了个习惯:上线前把权限、路由、菜单、配置这四块拆成"角色矩阵"来扫,不是看代码对不对,是看能走到哪一步。

一、权限:别只测 admin,造几个"脏角色"

WordPress 的 capability 是个坑,manage_optionsmanage_categories 之间隔着一整个宇宙。我现在会专门写段"角色工厂"代码,发版前跑一遍:

// 临时造三个脏角色,测完即删
$test_roles = [
    'content_ops'   => ['edit_posts', 'edit_others_posts', 'publish_pages'],
    'limited_admin' => ['manage_options'], // 有后台权限但无内容权限
    'custom_mess'   => ['read', 'my_plugin_custom_cap'], // 只给了自定义权限
];

foreach ( $test_roles as $slug => $caps ) {
    $role = add_role( $slug, "Test: $slug", array_fill_keys( $caps, true ) );
    wp_set_current_user( get_user_by( 'role', $slug )?->ID ?? 0 );
    
    // 关键:不是测能不能进后台,是测每个 ajax handler、每个 admin page 的回调
    do_action( 'my_plugin_capability_audit', $slug );
}

重点不是角色有没有创建成功,是你的每个入口点(admin page、ajax、rest route、metabox)有没有在开头就 wp_die() 掉不该进来的人。我吃过亏:菜单用了 add_management_page 的 capability 参数过滤了,但直接访问 URL 绕过了菜单渲染,路由回调里没二次校验。

二、路由:REST 和 Admin-Ajax 的"暗门"

REST 路由的 permission_callback 我至少重写过三次。第一次忘了加,第二次加了但返回 true 偷懒,第三次正经写了但没用 current_user_can 而是自己判断 is_user_logged_in()——结果 Cookie 过期后前端拿到 200 空响应,调试了半小时。

现在我的路由注册长这样,permission_callback 必须显式、必须和菜单 capability 对齐:

register_rest_route( 'my-plugin/v1', '/batch-update/', [
    'methods'             => 'POST',
    'callback'            => [ $this, 'handle_batch' ],
    'permission_callback' => function () {
        // 和菜单 capability 保持一致,别发明新权限
        return current_user_can( 'manage_options' );
    },
] );

Admin-ajax 更阴:它没有内置的 permission 机制,全靠你在 wp_ajax_* 回调里手写 check_ajax_referer + current_user_can。我现在的模板是:

public function handle_ajax() {
    check_ajax_referer( 'my_plugin_nonce', 'nonce' );
    
    $cap = $_POST['context'] === 'bulk' ? 'manage_options' : 'edit_posts';
    if ( ! current_user_can( $cap ) ) {
        wp_send_json_error( 'capability mismatch', 403 );
    }
    // ...
}

三、菜单:$position 是浮点数,但别真的乱浮

已有帖子聊过 3.00001 的坑,我补充个实际踩的:两个插件都用了 3.00001,结果后激活的把先激活的挤没了。现在我的做法是在构造函数里声明"菜单锚点"常量,如果冲突至少报错明显:

const MENU_POSITION = 3.00001; // 媒体(4) 之前,评论(25) 远着呢

public function register_menu() {
    global $menu;
    
    // 发版前检查:这个位置有没有被占?
    $occupied = array_filter( $menu, fn( $m ) => isset( $m[5] ) && abs( $m[5] - self::MENU_POSITION ) < 0.0001 );
    if ( $occupied && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        error_log( 'Menu position collision at ' . self::MENU_POSITION );
    }
    
    add_menu_page( /* ... */, self::MENU_POSITION );
}

子菜单的 parent_slug 也要对:如果父菜单是顶级菜单,slug 必须完全匹配,大小写敏感。我有一次把 my-plugin 写成 my_plugin,子菜单独立成了一个顶级菜单,找了二十分钟。

四、配置:option 的"幽灵默认值"

get_option( 'my_plugin_setting' ) 返回 false 时,是选项不存在、还是存了 false、还是存了空字符串?我现在的配置初始化用"显式 schema + 一次性合并":

public function get_config( $key = null ) {
    $defaults = [
        'api_endpoint' => '',
        'cache_ttl'    => 3600,
        'enable_log'   => false, // 明确 false,不是省略
    ];
    
    $stored = get_option( 'my_plugin_config', [] );
    // 发版前检查:如果有存了的 key 不在 defaults 里,是旧版本残留还是拼写错误?
    $orphan = array_diff_key( $stored, $defaults );
    if ( $orphan && is_admin() ) {
        add_action( 'admin_notices', function () use ( $orphan ) {
            printf( '<div class="notice notice-warning">Orphan config keys: %s</div>', implode( ', ', array_keys( $orphan ) ) );
        } );
    }
    
    $config = wp_parse_args( $stored, $defaults );
    return $key ? $config[ $key ] : $config;
}

五、我的实际 Checklist(贴在编辑器旁边)

最后贴张我打印的纸,发版前逐项打钩,不凭记忆:

  • □ 创建"内容运营""只读审核""自定义权限"三个测试角色,每个角色走一遍完整流程
  • □ REST 路由:未登录、登录无权限、登录有权限,三种状态测 OPTIONS 和实际请求
  • □ Admin-ajax:直接 curl 调用,不带 nonce、带错 nonce、带对 nonce 但权限不足
  • □ 菜单:换不同角色登录,看入口显不显示;复制 URL 隐身窗口直接访问
  • □ 配置:全新安装(无 option 记录)和升级安装(有旧 option)各跑一次,看默认值是否污染
  • □ 多站点:在子站点激活,看 get_blog_optionget_option 行为是否分裂

最上面那行红字是我用马克笔写的:" admin 能过 ≠ 能上线 "

你们发版前有没有被自定义角色坑过?或者菜单位置冲突是怎么解决的?

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