后台接口"裸奔"五分钟:我如何把 `admin_init` 的鉴权钩子挂成了 `init`,让访客差点拿到全站用户列表
上周给积分插件加了一个"批量导出用户积分流水"的后台接口,本地测完功能正常,顺手推了预发。结果测试同事在没登录的情况下,直接 curl 到了数据——我盯着返回的 JSON 愣了十秒,才发现钩子里的 `admin_init` 被我手滑写成了 `init`。
这个低级错误让我重新扒了一遍 WordPress 后台鉴权的"暗桩",发现几个容易踩的坑,记下来给自己长个记性。
一、钩子选错 = 城门大开
WordPress 的权限检查不是自动生效的,它依赖你挂在正确的钩子上。后台请求的标准入口是 `admin_init` 或 `admin_ajax_*`,但这两个钩子只在 `/wp-admin/` 路径下触发。如果你把鉴权逻辑挂在 `init` 上,再自己判断 `is_admin()`,就等于把判断时机交给了请求参数——而 `is_admin()` 本身只检查 URL 是否包含 `/wp-admin/`,并不验证用户身份。
更隐蔽的是 REST API 路由。很多人以为注册了 `permission_callback` 就万事大吉,但如果用 `register_rest_route` 时漏了这个参数,WordPress 不会报错,而是默认允许所有请求。
// 错误的"自我安慰式"写法
add_action( 'init', function() {
if ( is_admin() && ! current_user_can( 'manage_options' ) ) {
wp_die( '权限不足' );
}
// 实际业务逻辑...
} );
// 正确的后台入口
add_action( 'admin_init', function() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( '权限不足' );
}
// 实际业务逻辑...
} );
// REST API 必须显式声明权限回调
register_rest_route( 'my-plugin/v1', '/export', array(
'methods' => 'POST',
'callback' => 'my_export_callback',
'permission_callback' => function() {
return current_user_can( 'manage_options' );
}, // 漏掉这行 = 公开接口
) );
二、`check_ajax_referer` 不是万能盾牌
AJAX 接口里常用 `check_ajax_referer( 'my_nonce', 'security' )`,但它只验证 nonce 的合法性和来源域名,不检查用户角色。也就是说,一个订阅者用户拿到合法 nonce 后,照样能调用你的 AJAX 接口。
我现在的习惯是"双检":nonce 防 CSRF,`current_user_can` 防越权。两者缺一不可。
// 只验 nonce,订阅者也能过
check_ajax_referer( 'my_action', 'security' );
// 必须补角色检查
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( '越权访问', 403 );
}
三、SQL 注入的"参数化"幻觉
用 `$wpdb->prepare` 不代表绝对安全。我之前见过一个案例:开发者把 `ORDER BY` 的列名也放进 `prepare` 的参数位,结果 SQL 语法报错——因为 `prepare` 只能转义值,不能转义标识符(表名、列名)。
// 错误:列名不能用 %s 占位
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY %s DESC", $_GET['sort'] );
// 正确:列名必须白名单校验
$allowed_sort = array( 'id', 'created_at', 'points' );
$sort_column = in_array( $_GET['sort'], $allowed_sort, true )
? $_GET['sort']
: 'id';
// 列名直接拼接,值才用 prepare
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$table} ORDER BY {$sort_column} DESC LIMIT %d",
absint( $_GET['limit'] )
) );
另外,`$wpdb->insert()` 和 `$wpdb->update()` 底层也是参数化的,但它们的第三个参数 `$where` 如果直接塞用户输入,一样会炸。`$where` 的键是列名,不会被转义。
四、CSRF 的"同源"陷阱
WordPress nonce 机制依赖 session cookie,但如果你在插件里开了跨域 CORS,或者用了 `fetch` 的 `credentials: 'include'`,就要小心了。nonce 本身没有绑定到具体 action 的语义,它只是证明"这个请求来自同一用户的同一会话"。
我现在给敏感操作加了两层:nonce + 操作签名。用 `wp_create_nonce( 'my_action_' . $user_id )` 替代通用的 `wp_create_nonce( 'my_action' )`,这样即使 nonce 泄露,攻击者也无法换用户复用。
五、一个快速自检的脑图
每次写后台接口前,我现在会过一遍这个清单:
1. 入口钩子是不是 `admin_init` / `admin_post_*` / `wp_ajax_*`?
2. REST API 的 `permission_callback` 写没写?返回值是不是布尔?
3. nonce 校验后有没有跟 `current_user_can`?
4. SQL 里有没有字符串拼接的标识符?`prepare` 的参数位是不是全是值?
5. 输出有没有 `wp_send_json` 统一格式,避免直接 `echo` 暴露路径?
那次 `init` 挂错钩子的代码,已经在 CI 里加了一条正则扫描:禁止在 `init` 钩子下直接处理 `$_GET` / `$_POST` 的导出/删除类操作。低级错误靠流程拦,比靠记忆靠谱。
你们有没有类似的"鉴权幻觉"翻车经历?比如以为挂了钩子就安全、以为 prepare 万能之类的,欢迎丢出来一起复盘。