后台表单提交被绕过后,我才意识到 `check_token` 不是万能的:Zsens Admin 插件开发中鉴权纵深与输入清洗的五个真坑

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

上周接了个二次开发需求,客户说后台某配置页"偶尔"能不用登录直接访问。我本地复现了半天没成功,直到对方发来一个 curl 命令——原来他直接把表单 POST 到了旧域名,而新部署的实例没清缓存,中间件链里漏了一道校验。这件事让我重新梳理了一遍 Zsens Admin 插件里那些"看起来做了、实际没做全"的安全口子。

坑一:Token 校验放错位置,等于没放

很多开发者习惯在 Controller 入口加 `check_token()`,但 Zsens 的钩子执行顺序里,部分路由会先触发 `app_init` 或 `controller_init`。如果你在 `admin_init` 才校验,前面已经跑完的钩子可能已经被恶意请求利用。我的做法是拆成两道:路由分发前做白名单匹配,业务方法内再做 Token 二次确认。

// 错误示范:只在 save 方法里校验
public function save()
{
    $this->check_token(); // 此时前置钩子已执行完毕
    // ...
}

// 调整:中间件层先拦一道
public static function appInit($params)
{
    $route = request()->pathinfo();
    $whitelist = ['login/index', 'captcha'];
    if (!in_array($route, $whitelist) && !session('admin_id')) {
        abort(403, '未授权访问');
    }
}

坑二:CSRF Token 复用导致横向越权

Zsens 默认的 Token 是基于 session 生成的,这意味着同一用户多个标签页共享同一个 Token。我在某次开发中偷懒,把配置 A 页面的 Token 塞进了配置 B 的 ajax 请求头里——居然过了。更麻烦的是,如果用户同时开着超级管理员和普通管理员两个账号(不同浏览器或隐私模式),Token 的绑定粒度不够细,存在替换攻击面。

现在的方案是给敏感操作加操作级随机码:表单渲染时生成一次性 `action_nonce`,存入 Redis 并设 5 分钟 TTL,提交时比对 `nonce + uid + action` 的三元组。虽然多了次缓存查询,但杜绝了 Token 的横向漂移。

// 生成时绑定到具体动作
$nonce = md5(uniqid() . $adminId . $actionName);
Cache::set("nonce:{$adminId}:{$actionName}", $nonce, 300);

// 校验时三元匹配
$inputNonce = input('post._nonce');
$cacheNonce = Cache::get("nonce:{$adminId}:{$actionName}");
if (!$cacheNonce || !hash_equals($cacheNonce, $inputNonce)) {
    abort(419, '请求已过期或非法');
}

坑三:参数化查询的"半吊子"写法

都知道要防注入,但 Zsens 的 Db 类里有些方法容易让人放松警惕。比如 `whereRaw` 和 `fieldRaw`,我看过太多代码直接把前端传进来的排序字段拼进去:

// 危险
Db::name('config')->order(input('sort_field') . ' ' . input('sort_order'))->select();

// 更隐蔽的危险:以为用了参数化,实际 order by 部分仍是拼接
Db::name('config')->where('id', 'in', $ids)->orderRaw("FIELD(id, {$idStr})");

我的原则是:凡是进 SQL 关键字的(order by、group by、limit 偏移量),一律白名单校验,不跟前端传值做"信任但验证"的妥协。排序字段只允许多个预定义常量,方向只接受 ASC/DESC 两个字符串硬匹配。

坑四:权限校验的"后置补票"

Zsens 的节点权限是按 URL 路径匹配的,但 RESTful 风格的路由容易漏检。比如 `/admin/user/1` 和 `/admin/user/2` 是同一个节点,可如果我在方法里根据 ID 做了数据范围隔离(A 部门只能看 A 部门的数据),这个隔离逻辑往往写在 Service 层甚至 Model 层,而不是权限校验阶段。

结果?有人改个 ID 就能触达别人的数据,虽然最终查询被数据范围拦住返回空,但请求本身已经穿透到了业务层。我现在把数据范围校验也抽象成前置策略,挂在中间件链里,没通过范围校验的直接 403,不进 Controller。

// 在路由中间件里绑定数据范围
$adminDeptId = session('admin.dept_id');
$targetDeptId = input('param.dept_id', 0);

if ($targetDeptId && $targetDeptId != $adminDeptId) {
    // 记录审计日志后直接断掉
    Log::warning("越权访问尝试: admin={$adminId}, target_dept={$targetDeptId}");
    abort(403);
}

坑五:日志只记成功、不记失败

这是最容易被忽视的点。我们花了大力气做鉴权,但攻击者一直在试探边界,如果日志里只有"某某保存了配置",没有"某某因 Token 失效被拒绝",你根本不知道自己已经被扫描了多少轮。我在 Zsens 插件里单独加了一个 `security_log` 表,专门收集校验失败事件:Token 错误、Nonce 过期、数据范围越界、SQL 拦截触发,全部落盘,定期跑统计看有没有 IP 集中爆破。

最后提一个踩过的具体场景:某次升级 Zsens 核心框架,官方把 `input()` 的默认过滤从 `htmlspecialchars` 改成了 `strip_tags`,我插件里有个配置项是允许存 JSON 的,结果升级后所有带尖括号的 JSON 被洗成了空字符串。这不算安全漏洞,但说明输入清洗策略的变更会间接破坏业务预期——安全不是单点加固,是整个输入处理链路的契约维护。

大家在做 Zsens Admin 插件的安全层时,有没有遇到过"校验通过了、但实际还是被绕"的诡异情况?欢迎贴场景讨论,我这边还有几个没完全想清楚的边界 case。

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