后台鉴权别只盯 `current_user_can`,这三个"隐形后门"让我背过两次 P0 事故
写插件这几年,权限漏洞出过两回血,都不是 `current_user_can` 没写,而是写了但没用对地方。今天把三个最隐蔽的鉴权陷阱摊开聊,附带我当时踩坑的代码和修补思路。
陷阱一:REST 端点的 `permission_callback` 成了"摆设回调"
WordPress 5.5 之后注册 REST 路由必须写 `permission_callback`,很多人直接扔个 `__return_true` 或者抄段 `is_user_logged_in()` 完事。但这里有个细节:如果你的回调里调了 `current_user_can`,却没处理用户未登录时返回的 `null` 能力值,逻辑会静默跑偏。
我当时的翻车代码:
register_rest_route('myplugin/v1', '/sensitive-action', [
'methods' => 'POST',
'callback' => [__CLASS__, 'handle_sensitive'],
'permission_callback' => function () {
// 坑:未登录用户 wp_get_current_user() 返回 ID 为 0 的对象
// current_user_can('manage_options') 对 ID 0 永远返回 false
// 但这里我顺手加了 || is_super_admin(),多站点下未登录时 is_super_admin(0) 居然返回 true!
return current_user_can('manage_options') || is_super_admin();
},
]);
多站点环境里 `is_super_admin()` 不传参或传 0 时的行为极其诡异,文档里不会告诉你。修补后我强制显式传当前用户 ID,并且把 `is_super_admin` 包进 `is_multisite()` 判断:
'permission_callback' => function () {
$user = wp_get_current_user();
if (!$user->exists()) {
return false;
}
return current_user_can('manage_options')
|| (is_multisite() && is_super_admin($user->ID));
}
陷阱二:AJAX 动作的 `nopriv` 与 `wp_ajax_` 的"权限真空"
后台 AJAX 很多人写 `wp_ajax_my_action` 时顺手鉴权,但漏了同名的 `wp_ajax_nopriv_my_action` 钩子——或者更阴险的是,两个钩子指向同一个处理函数,函数内部只在某个分支判断权限,另一个分支直接执行。
我重构过一段第三方插件的代码,结构大概这样:
add_action('wp_ajax_myplugin_export', [$this, 'export_data']);
add_action('wp_ajax_nopriv_myplugin_export', [$this, 'export_data']); // 这里开了 nopriv
public function export_data() {
if (isset($_POST['admin_mode'])) {
check_ajax_referer('admin_export', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('无权访问');
}
// 执行管理员导出...
}
// 非 admin_mode 分支直接走,没有任何鉴权!
$this->do_public_export($_POST);
wp_send_json_success();
}
攻击者直接 POST 不带 `admin_mode` 的数据,绕过所有权限检查。修复方案是钩子入口即鉴权,不要指望函数内部分支兜底:
public function export_data() {
// 所有路径必须先过 nonce + 能力检查
check_ajax_referer('myplugin_export', 'nonce');
$cap = isset($_POST['admin_mode']) ? 'manage_options' : 'read';
if (!current_user_can($cap)) {
wp_send_json_error('权限不足', 403);
}
// ...后续逻辑
}
陷阱三:CSRF Token 的"重放窗口"与 `check_admin_referer` 的盲区
WordPress 的 nonce 不是真正的"一次性令牌",默认 12-24 小时有效。更麻烦的是,同一个 nonce 在有效期内可以被无限次使用。如果你的插件有"删除全部数据"这类高危操作,标准 nonce 不够。
我现在的做法是给敏感操作加操作级令牌 + 用户会话绑定:
public function generate_destructive_nonce(string $action): string {
$user_id = get_current_user_id();
$session_token = wp_get_session_token(); // 用户当前会话唯一标识
// 绑定到具体用户 + 具体会话,换浏览器/清 Cookie 立即失效
$hash = wp_hash($action . '|' . $user_id . '|' . $session_token . '|' . wp_rand(), 'nonce');
set_transient('myplugin_destructive_' . $hash, [
'user_id' => $user_id,
'action' => $action,
'used' => false,
], HOUR_IN_SECONDS);
return $hash;
}
public function verify_destructive_nonce(string $nonce, string $action): bool {
$data = get_transient('myplugin_destructive_' . $nonce);
if (!$data || $data['used'] || $data['action'] !== $action) {
return false;
}
// 标记已使用,彻底防重放
$data['used'] = true;
set_transient('myplugin_destructive_' . $nonce, $data, HOUR_IN_SECONDS);
return $data['user_id'] === get_current_user_id();
}
代价是多一次 transient 读写,但高危操作值得这个开销。
SQL 注入的"边缘案例":IN 子句与动态表名
最后顺嘴提个 SQL 注入的冷门场景。`$wpdb->prepare` 处理不了 `IN (...)` 里的数组,很多人直接 `implode(',', $ids)` 拼进去。如果 `$ids` 来源是用户输入的字符串数组,哪怕做了 `intval` 逐个转,中间也可能被注入注释符截断。
更隐蔽的是动态表名:`$wpdb->prefix . $_GET['table']` 这种写法,前缀可控时直接变成任意表操作。我现在强制用白名单映射:
$allowed_tables = ['myplugin_logs', 'myplugin_settings'];
$table = $_GET['table'] ?? '';
if (!in_array($table, $allowed_tables, true)) {
wp_die('非法表名');
}
权限这块没有"差不多就行",每个入口都要假设会被直接调用。你们有遇到过更阴间的鉴权绕过方式么?