Zsens Admin 插件开发:权限绕过与伪造请求的攻防实战——从 Token 校验到参数化查询的三层加固方案
在 Zsens Admin 插件开发中,权限与安全往往是最容易被"想当然"处理的环节。本文基于 ThinkPHP 8 + PHP 8.2 环境,聚焦三个真实踩坑场景,分享从鉴权链路、CSRF 防御到 SQL 注入防护的完整加固思路。
一、后台鉴权:别只依赖中间件,控制器内二次校验是最后防线
Zsens Admin 提供了全局鉴权中间件,但插件开发中常见一个误区——认为"进了控制器就等于权限合法"。实际项目中曾遇到这样的漏洞:某插件通过 URL 直接访问 /admin/plugin/xxx/export,虽然中间件校验了登录态,但未校验当前用户是否拥有该插件的"数据导出"节点权限,导致低权限客服账号可导出全量用户数据。
正确的做法是在控制器方法内显式调用节点校验:
use zsens\admin\library\Auth;
public function export()
{
// 二次校验:当前用户是否具备本插件的 export 权限节点
if (!Auth::check('plugin/demo/export')) {
throw new HttpException(403, '无权访问该功能');
}
// 业务逻辑...
}
更隐蔽的风险在于"权限提升":当插件提供了"代操作"功能(如管理员帮用户提交订单),务必校验被操作对象是否在当前管理员的数据权限范围内,而非仅校验功能节点。建议结合 Zsens Admin 的数据范围注解 @DataAuth 做二次过滤。
二、CSRF 防护:AJAX 场景下的 Token 生命周期与双重提交陷阱
ThinkPHP 8 内置了 {:token()} 表单令牌,但插件开发中纯 AJAX 接口容易被忽略。曾有个案例:插件提供了"批量启用"的 POST 接口,前端通过 axios 调用,但开发者未在请求头携带 X-CSRF-TOKEN,同时关闭了该路由的表单令牌验证——结果构造恶意页面即可触发管理员身份的批量操作。
推荐的分层方案:
1. 常规表单页:沿用 TP 默认的 __token__ 字段校验,Zsens Admin 的视图层会自动注入;
2. AJAX 接口:在插件初始化时向前端暴露一次性的 API Token,采用"双重 Cookie 验证"模式:
// 插件基类或中间件中设置
$response->header('X-Plugin-Token', csrf_token());
// 前端 axios 拦截器统一携带
axios.defaults.headers.common['X-Plugin-Token'] = document.querySelector('meta[name="csrf-token"]').content;
3. 关键操作(如删除、资金变动):引入"操作密码"二次确认,独立于登录态,即使 CSRF Token 泄露也能阻断。
特别注意:TP8 的 Token 默认有效期为 1200 秒,插件若存在长表单编辑场景(如文章排版超过 20 分钟),需在 JS 层做 Token 刷新机制,避免合法提交被拦截。
三、SQL 注入:Think ORM 不是万能盾牌,原生查询的五个危险切面
Think ORM 的查询构造器确实提供了基础防护,但插件开发中以下场景极易绕过:
切面一:动态表名与字段名
插件常需支持"自定义字段扩展",错误写法:
// 危险!$field 来自用户提交
Db::table('plugin_data')->order("$field DESC")->select();
字段白名单校验是必须的:
$allowFields = ['id', 'create_time', 'sort'];
$field = in_array($input['field'], $allowFields) ? $input['field'] : 'id';
Db::table('plugin_data')->order([$field => 'DESC'])->select();
切面二:原生 WHERE 子句拼接
复杂统计需求下,开发者倾向于 whereRaw,但参数绑定位置错误会导致注入:
// 错误:参数未绑定
Db::table('log')->whereRaw("create_time > '$startTime'")->select();
// 正确:使用位置绑定或命名绑定
Db::table('log')->whereRaw('create_time > :start', ['start' => $startTime])->select();
切面三:JSON 字段的查询语法
MySQL 5.7+ 的 JSON 查询在 ORM 中支持有限,手写 -> 或 ->> 路径时,路径片段若含用户输入需转义:
// 危险:$path 可控
Db::query("SELECT * FROM config WHERE json_data->>'$.$path' = ?", [$value]);
// 安全:路径白名单 + 参数分离
$safePath = preg_replace('/[^a-zA-Z0-9_]/', '', $path);
Db::query("SELECT * FROM config WHERE json_data->> ? = ?", ["$.{$safePath}", $value]);
切面四:批量操作的 IN 子句
插件的批量删除接口常见写法:
$ids = $request->post('ids/a'); // 数组
Db::table('item')->whereIn('id', $ids)->delete();
Think ORM 底层会对数组做参数化绑定,但若 $ids 被篡改为字符串(如通过修改 Content-Type 绕过数组类型强制),可能触发解析异常。建议前置类型校验:
$ids = array_filter((array)$ids, 'is_numeric');
if (count($ids) > 1000) { // 限制批量上限,防 DOS
throw new ValidateException('单次操作超出上限');
}
切面五:插件间的数据隔离
多租户或 SaaS 场景下,插件表常含 site_id 字段。务必在模型基类中强制注入全局作用域,而非依赖每个查询手动添加:
// 插件模型基类
protected static function init()
{
parent::init();
static::addGlobalScope('site', function ($query) {
$query->where('site_id', Request::header('X-Site-Id'));
});
}
遗漏此步骤将导致跨站点数据泄露,且该漏洞往往隐藏在正常的功能测试之外。
结语
安全不是单一校验点的责任。在 Zsens Admin 插件开发中,建议建立"中间件初筛 → 控制器再审 → 模型层兜底"的三层校验习惯,同时对所有用户可控输入保持怀疑——即使它来自"已登录的管理员"。定期使用 php think optimize:route 清理冗余路由,减少攻击面,也是常被忽视的基础运维动作。



