后台表单校验信了前端 `disabled` 字段,结果 `$_POST` 里凭空冒出个 `is_admin=1`:一次关于"客户端不可信"的权限穿透复盘
上周帮朋友审一个内部插件,发现一个挺经典的思路盲区:后台配置页里有个"开启高级模式"的开关,只有超级管理员能看到。前端代码里用了 current_user_can('manage_options') 判断是否渲染这个 checkbox,普通用户确实看不到——但保存逻辑里直接读了 $_POST['is_admin'],没做二次校验。
攻击简单到离谱:普通用户 F12 把任意 input 的 name 改成 is_admin,value 设 1,提交后直接拿到高级权限。前端隐藏不等于后端不存在,这条线很多开发者知道,但写起来一顺手就漏。
我现在的"双闸"校验模式
第一道闸:表单渲染阶段, capability 检查只是 UI 控制,不产生任何安全承诺。
// 前端该藏藏,但别把这当安全措施
if ( current_user_can( 'manage_options' ) ) {
printf(
'<label><input type="checkbox" name="is_admin" value="1" %s> 高级模式</label>',
checked( get_option( 'my_plugin_advanced' ), 1, false )
);
}
第二道闸:保存时必须重新核定当前用户身份,且只处理该身份允许处理的字段。我现在的习惯是把字段按权限分级,不是"收到什么存什么"。
public function save_settings() {
// 基础 nonce 校验先过
if ( ! wp_verify_nonce( $_POST['_my_nonce'] ?? '', 'my_plugin_save' ) ) {
wp_die( '校验失败' );
}
// 保存公共字段:所有授权用户都能改
$public_fields = [ 'api_endpoint', 'cache_ttl' ];
foreach ( $public_fields as $field ) {
if ( isset( $_POST[ $field ] ) ) {
update_option( "my_plugin_{$field}", sanitize_text_field( $_POST[ $field ] ) );
}
}
// 敏感字段:必须重新验权,且与前端渲染时的 capability 严格对齐
if ( isset( $_POST['is_admin'] ) ) {
if ( ! current_user_can( 'manage_options' ) ) {
// 记录异常访问,但不中断流程(避免暴露检测逻辑)
do_action( 'my_plugin_permission_violation', wp_get_current_user(), 'is_admin' );
// 直接丢弃,不报错,不让攻击者知道触发了防护
} else {
update_option( 'my_plugin_advanced', 1 );
}
}
}
CSRF 的隐藏变体:nonce 复用陷阱
这个插件之前还踩过一个坑:nonce 是按页面生成的,但保存接口是通用的 admin-post.php。用户同时开两个配置页 Tab,A 页面的 nonce 在 B 页面提交时失效——这算正常防御。但开发者为了"用户体验",把 nonce 有效期改成了 24 小时,还做了"失败自动刷新":前端检测到 403 就静默重试,带着当前页面的新 nonce 再发一次。
问题出在"静默重试"没做次数限制。攻击者构造一个恶意页面,用 <form> 自动提交到目标站点的保存接口,带上从公开页面获取的有效 nonce(因为有效期太长),用户只要访问过目标站点的后台,浏览器里的登录态 + 有效 nonce 就能让请求通过。
我的调整:nonce 有效期缩回默认的 12-24 小时(WordPress 实际是 1 天,但我会按操作敏感度自定义),且敏感操作必须二次确认或引入用户交互证明(比如需要点击的确认按钮,攻击者无法自动触发)。
SQL 注入的"参数化幻觉"
插件里有个搜索功能,用了 $wpdb->prepare,看起来安全:
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_logs WHERE action = %s AND created_at > %s",
$_POST['action'],
$_POST['start_date']
);
但 ORDER BY 字段是从前端传的,开发者觉得"这是白名单控制的":
$allowed = [ 'created_at', 'user_id', 'action' ];
$orderby = in_array( $_POST['orderby'], $allowed ) ? $_POST['orderby'] : 'created_at';
$sql .= " ORDER BY {$orderby} DESC";
表面没问题,直到我发现 $allowed 数组是从数据库配置表里读的——而配置表的写入接口,恰好就是前面那个能被普通用户篡改的保存逻辑。权限穿透 → 篡改白名单 → 注入 ORDER BY (SELECT ...),链条完整了。
现在的做法:白名单硬编码在代码里,不接受任何外部配置;如果非要动态,用映射表而不是直接拼接:
$field_map = [
'time' => 'created_at',
'user' => 'user_id',
'act' => 'action',
];
$orderby = $field_map[ $_POST['orderby'] ?? '' ] ?? 'created_at';
// 确定是代码里的字面量,再拼接
$sql .= " ORDER BY {$orderby} DESC";
一个排查技巧:在测试环境故意"变坏"
我现在写保存逻辑时,会专门写一个"恶意提交"的测试 case:用最低权限角色登录,构造包含所有敏感字段的 POST 数据,验证服务端是否全部丢弃。这比"用管理员账号点一遍正常流程"更能发现问题。
WordPress 的权限体系钩子多、隐式规则也多,current_user_can 只是入口,不是终点。你们有没有遇到过"前端藏得好好的,后端直接信任"导致的穿透案例?或者 map_meta_cap 里藏的自定义 capability 在第三方插件冲突时的诡异行为?