插件上线前最后一遍 walkthrough:我整理的「权限-路由-菜单-配置」四维度逐项确认表

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

上周又踩了个坑——本地测得好好的插件,上到 staging 发现超级管理员能看到菜单,普通管理员点进去直接白屏。根因是 add_menu_page 的 capability 参数和实际页面里的 current_user_can 校验用了两个不同的字符串,一个 manage_options 一个 manage_plugin_xyz。这种"半开门"状态比完全没权限更危险,因为扫描器能嗅到路由存在。

现在每次打包前我都过一遍这个清单,四个维度各几个硬检查点,不依赖记忆,直接打勾。

一、权限维度:别只校验一次

我的插件通常有三处权限检查,容易漏的是第 2、3 处:

// 1. 菜单注册层(最常被想到)
add_menu_page(
    'XXX 设置',
    'XXX 设置',
    'manage_options',        // ← 这里
    'xxx-settings',
    'xxx_render_page'
);

// 2. 页面渲染层(很多人直接 echo,忘了)
function xxx_render_page() {
    if ( ! current_user_can( 'manage_options' ) ) {  // ← 这里
        wp_die( '无权访问' );
    }
    // ...
}

// 3. AJAX/REST 处理层(最容易漏,因为前端"看起来"没入口)
add_action( 'wp_ajax_xxx_save', function() {
    check_ajax_referer( 'xxx_nonce', 'nonce' );      // 防 CSRF,不防越权!
    if ( ! current_user_can( 'manage_options' ) ) {  // ← 这里必须补
        wp_send_json_error( '权限不足', 403 );
    }
} );

一个快速自查:全局搜 wp_ajax_register_rest_route,看每个回调的第一行是不是 current_user_cancheck_ajax_referer。只挂 nonce 没挂能力校验的,都是潜在后门。

二、路由维度:REST 和 Admin-AJAX 两条线

REST 路由我习惯显式声明 permission_callback,但有个细节:如果路由是在 rest_api_init 里注册的,确认钩子优先级。我之前有个插件在 rest_api_init 优先级 20 注册,结果被另一个插件在优先级 10 里 rest_api_default_filters 给拦截了,返回 404 调了半小时。

// 现在我会加这个"路由可达性"快速测试
// 直接 curl 或者浏览器开无痕,不带 cookie 访问
// 应该 401/403,如果 200 说明 permission_callback 漏了
// 如果 404 说明路由根本没挂上

add_action( 'rest_api_init', function () {
    register_rest_route( 'xxx/v1', '/data', [
        'methods'             => 'GET',
        'callback'            => 'xxx_get_data',
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },
    ] );
} );

Admin-AJAX 那边,nopriv 前缀的钩子要单独列出来审查。我的原则是:能不用 wp_ajax_nopriv_ 就不用,非用不可的话,回调里必须有业务层面的身份校验(比如 token、签名),不能裸奔。

三、菜单维度:多站点和子菜单的"继承陷阱"

主菜单和子菜单的 capability 可以不一致,这是特性也是坑。如果你这样写:

add_menu_page( 'XXX', 'XXX', 'manage_options', 'xxx-top', ... );
add_submenu_page( 'xxx-top', '子页', '子页', 'edit_posts', 'xxx-sub', ... );

那么拥有 edit_posts 但没 manage_options 的用户,看不到父菜单,但如果直接猜 URL /wp-admin/admin.php?page=xxx-sub,是能访问的。这种"孤儿子菜单"在渗透测试里很常见。

我的做法:子菜单 capability 必须 >= 父菜单,或者干脆统一常量。多站点环境下再加一层:

if ( is_multisite() && is_network_admin() ) {
    // 网络级菜单用 manage_network_options
    // 且要确认 is_network_admin() 和当前实际 URL 匹配
    // 别在 /wp-admin/network/ 下注册了菜单,回调里按单站点逻辑跑
}

四、配置维度:默认值、序列化、缓存失效的三元组

配置项我拆成三类检查:

1. 默认值是否"防呆"

// 坏的:空字符串作为默认值,后面判断 if ( $val ) 会漏掉 '0'
$val = get_option( 'xxx_count', '' );

// 好的:显式类型,且升级时补旧数据
$val = get_option( 'xxx_count', 0 );
if ( false === get_option( 'xxx_count' ) ) {
    // 首次安装,写默认值
    update_option( 'xxx_count', 0, false ); // 第三个参数 false = 不自动加载
}

2. 复杂结构别直接丢序列化

WordPress 的 update_option 会自动序列化数组,但手动 wp_cache_set 不会。如果代码里混用两者,会出现数据库里是对的、缓存里是裸数组的奇观。我现在统一走 update_option,需要缓存时让 WP 自己管,或者显式 wp_cache_delete( 'alloptions', 'options' )

3. 保存后刷新是否"回退"

这是对象缓存的经典症状。我的调试脚本:

// 在 update_option 后立刻读回来
update_option( 'xxx_key', $new_value );
$fresh = get_option( 'xxx_key' );
// 如果 $fresh !== $new_value,说明缓存层有问题
// 常见于:update_option 被 hook 拦截、或者外部对象缓存(Redis/Memcached)没同步

五、一个 5 分钟自动化脚本

最后贴个我放在 bin/pre-release.sh 里的粗糙检查,CI 跑不过不让打包:

#!/bin/bash
# 检查所有 REST 路由都有 permission_callback
grep -r "register_rest_route" src/ | while read -r line; do
    if [[ "$line" != *"permission_callback"* ]]; then
        echo "缺失 permission_callback: $line"
        exit 1
    fi
done

# 检查所有 wp_ajax_ 回调都有 current_user_can
grep -r "wp_ajax_" src/ | grep -v "current_user_can" | while read -r line; do
    echo "疑似缺失权限校验: $line"
done

# 检查菜单 capability 一致性(粗略)
grep -r "add_menu_page\|add_submenu_page" src/ | grep -oP "capability\s*=>\s*'[^']+'" | sort | uniq -c

这个脚本会误报,但不会漏报。误报手动过,漏报可能半夜 pager。

你们上线前有什么必查项?尤其是那种"本地永远复现不了,一上生产就炸"的。我这份清单已经补了七八次了,感觉还能再长。

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