后台 Ajax 接口"半开半掩":我如何用 `check_ajax_referer` 与自定义 capability 织了一张"双层滤网"
上周给插件加了一个"批量导出用户行为日志"的后台 Ajax 接口,本地测得飞起,上线第二天就被安全组扫出"未授权访问"——明明挂了 nonce,访客怎么还能触发?追查下来发现是check_ajax_referer和权限校验的"职责真空"被我搞混了,今天把踩的坑摊开聊聊。
第一层坑:nonce 不是身份,只是"票根"
我最开始的代码长这样,看着挺规矩:
add_action( 'wp_ajax_myplugin_export_logs', function() {
check_ajax_referer( 'myplugin_export', 'nonce' );
// ... 直接读表、拼 CSV、输出
} );
问题出在wp_ajax_*和wp_ajax_nopriv_*的区别上。我只注册了wp_ajax_myplugin_export_logs,想当然觉得"没登录的人走不到这里"。但 WordPress 的wp_ajax_*钩子只检查用户是否登录,登录了就能进,不管你是订阅者还是超管。
更隐蔽的是,如果别处泄了一个有效 nonce(比如前端页面缓存没清、或者某个短码把 nonce 打印给了低权限用户),攻击者拿着这张"票根"就能直接调接口。nonce 防的是 CSRF,不是 RBAC。
第二层坑: capability 校验别偷懒,但别硬编码
补上权限检查的第一反应是写死:
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( '无权访问', 403 );
}
这在单站点能跑,多站点里manage_options的语义会变(超级管理员 vs 站点管理员)。后来我改成了插件自定义 capability,安装时通过register_activation_hook映射到角色:
// 激活时注册
$role = get_role( 'administrator' );
$role->add_cap( 'myplugin_view_logs' );
// Ajax 回调里校验
if ( ! current_user_can( 'myplugin_view_logs' ) ) {
wp_send_json_error( 'capability 不匹配', 403 );
}
这样卸载时也能精准回收,不留"权限僵尸"。
第三层坑:nonce 的 scope 别太大
我最初全局用一个 nonce 字符串'myplugin_export',所有页面都通过wp_create_nonce( 'myplugin_export' )下发。结果某个前端小工具也拿到了这个 nonce,虽然它自己没调导出接口,但 nonce 泄露面扩大了。
现在的做法是把 nonce 和 capability 绑定生成,缩小传播范围:
// 只在真正需要导出的页面下发
wp_localize_script( 'myplugin-admin', 'myplugin_ajax', [
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_export_' . get_current_user_id() ),
] );
校验端同步调整:
check_ajax_referer( 'myplugin_export_' . get_current_user_id(), 'nonce' );
这样即使 nonce 被截,也绑定了用户 ID,无法跨账户复用。
第四层:SQL 注入的"最后一道缝"
导出接口支持按日期范围筛选,我早期直接拼接:
$where = "WHERE created_at BETWEEN '{$_POST['start']}' AND '{$_POST['end']}'";
虽然前面已经校验了登录和权限,但权限高不代表输入干净。管理员的浏览器如果被 XSS,或者插件之间互相调接口,这里照样是注入点。现在全部走$wpdb->prepare:
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}myplugin_logs
WHERE created_at BETWEEN %s AND %s",
sanitize_text_field( $_POST['start'] ),
sanitize_text_field( $_POST['end'] )
);
注意{$wpdb->prefix}这里别自己拼前缀常量,有人把表前缀设成wp_以外的值时,硬编码会炸。
现在的校验顺序,我固化成了模板
每个后台 Ajax 接口的回调,开头必须是这个顺序,不允许调换:
1. check_ajax_referer( $action, $query_arg ) // 防 CSRF
2. current_user_can( $custom_cap ) // 防越权
3. sanitize / validate 输入参数 // 防注入、防逻辑污染
4. 执行业务逻辑
第 1、2 步任何一项失败,直接wp_send_json_error( ..., 403 )退出,不给后面留机会。
有个细节:check_ajax_referer失败时默认会die(-1),这在 REST 时代显得有点粗暴。我包装了一个辅助函数,让它统一走 JSON 响应,方便前端统一处理错误码:
function myplugin_verify_ajax( $action, $query_arg = 'nonce' ) {
if ( ! check_ajax_referer( $action, $query_arg, false ) ) {
wp_send_json_error( [ 'code' => 'invalid_nonce' ], 403 );
}
}
第三个参数false是关键,阻止它直接 die,把控制权交回来。
一个没解决的纠结
插件如果跑在对象缓存后面,wp_create_nonce的 tick 窗口(默认 12 小时)会不会被缓存拉长?我测过 Memcached 场景,nonce 生成走的是wp_nonce_tick(),它基于时间除法,不查库,所以不受缓存影响。但如果有谁碰到过反例,欢迎拍砖。
你们后台 Ajax 的鉴权是怎么组织的?有没有把 nonce 和 JWT 或者 session fingerprint 做过二次绑定?想听听更重的做法。

