插件上线前最后一遍 walkthrough:我整理的「权限-路由-菜单-配置」四维度逐项确认表
上周又踩了个坑——本地测得好好的插件,上到 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_can 或 check_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。
你们上线前有什么必查项?尤其是那种"本地永远复现不了,一上生产就炸"的。我这份清单已经补了七八次了,感觉还能再长。

