后台 Ajax 接口"半开半掩":我如何用 `check_ajax_referer` 与自定义 capability 织了一张"双层滤网"

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

上周给插件加了一个"批量导出用户行为日志"的后台 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 做过二次绑定?想听听更重的做法。

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