后台权限校验我图省事用了"中间件白名单",结果一个接口参数绕过让普通用户直接调到了超管接口
上个月重构后台的时候,我把权限逻辑简化成了"中间件里检查角色ID是否在白名单数组里",想着反正内部系统人不多,能跑就行。结果安全测试的同事给我上了一课,到现在我还记着那个演示视频里他怎么用普通账号POST了一个`role_id=1`就拿到了全站数据导出权限。
这件事之后我把后台鉴权重新捋了一遍,发现之前踩的坑远不止这一个,挑几个最痛的记录一下。
一、中间件白名单的"参数污染"漏洞
我原来的逻辑是这样的:登录后在Session里写死用户的角色ID,中间件里比对`$_SESSION['role_id']`是不是在`[1,2,3]`里。看起来没问题对吧?但有个接口为了"灵活",接收了一个`?role_id=`参数用于"查询指定角色的数据",而我全局又开了`extract($_GET)`的老习惯。
测试同事的思路很直接:先正常登录拿到普通Session,然后请求超管接口时带上`?role_id=1`,`extract`把GET参数覆盖进了当前变量空间,中间件一检查——"哟,是1,放行"。
修复方案不是简单地禁用`extract`,而是把权限校验的上下文彻底隔离。现在我的中间件里只认`$request->session('auth_role')`,这个值在登录时由服务端根据数据库查询结果写入,任何请求参数都碰不到它。另外给所有后台路由加了`route_middleware`的严格匹配,不在注册表里的Action直接404,不给"猜接口"留机会。
二、CSRF防护的"异步陷阱"
后台表单都加了Token,但有个批量导入功能用了Ajax分片上传。前端每次POST都带了`X-CSRF-TOKEN`头,我后端也校验了,看起来万无一失。直到我发现这个Token是页面加载时写在`meta`标签里的,有效期是整个Session周期。
问题出在:用户开着后台标签页去喝了杯咖啡,回来继续操作。如果这期间他在另一个标签页点了退出登录,Session已经销毁了,但第一个标签页的页面没刷新,那个Token还在。更糟的是,如果这时候有恶意页面诱导他点击,带着这个"僵尸Token"的请求会被正常处理——因为Token本身还没过期,只是Session关联的用户上下文已经变了。
现在的做法是Token绑定`session_id + 用户指纹 + 时间窗口`,后端校验时不只看Token字符串,还要比对生成时的上下文指纹。Ajax请求如果收到`419`状态码,前端自动弹层要求重新认证,而不是静默失败或者无脑重试。
三、SQL注入的"排序字段"盲区
这个最丢人。列表页的分页查询我都用了参数化,但排序字段是前端传的`sort=create_time&order=desc`。我直接字符串拼接进了ORDER BY,想着"反正字段名是白名单校验过的"。
白名单确实写了,但校验逻辑是`in_array($_GET['sort'], ['id','create_time'])`,而传入`sort=id,1=1`的时候,`in_array`返回了`false`,我的fallback逻辑居然默认用`id`排序,直接把`id,1=1`拼进了SQL。虽然这次没造成实质注入,但数据库报错日志里那条`ORDER BY id,1=1 DESC`让我后背发凉。
现在的字段白名单改成了键值对映射:前端传`create_time`,后端映射到真实的`a.create_time`字段。任何不在映射表里的输入直接抛异常,没有fallback,没有默认排序。
四、接口权限的"继承漏洞"
用了RESTful风格之后,我图方便给资源控制器写了一个基类,里面统一加了`beforeAction`做鉴权。但有个子类重写了`delete`方法,忘了调`parent::beforeAction()`,导致这个删除接口裸奔了三天。
现在基类改成了`final`的`checkPermission`方法,子类只能通过配置数组声明当前Action需要的权限节点,不允许重写校验逻辑本身。权限节点和路由的绑定关系单独维护在一个配置组里,每次部署时跑脚本校验"有路由无节点"或者"有节点无路由"的孤儿数据。
写在最后
以前总觉得"内部系统不用这么严格",现在觉得安全这事就像备份——你觉得不会出事的时候,就是最容易出事的时候。最近还在看RBAC的细粒度改造,想把"菜单可见"和"接口可调用"彻底拆成两条线,而不是现在这种"能看到菜单就能调接口"的粗糙模式。有做过类似改造的老哥欢迎交流,我先去补那十几个漏掉的权限节点了。