一行 parkId 改不出越权:契约测试如何锁死 Token 身份来源
多园区切换接口 GET /user/userParkSwitch/{parkId} 能不能被前端伪造身份?改一行 parkId 就能越权切到其他园区吗?
这是多园区改造最容易出现的”看起来安全、其实没拦住”的安全漏洞。本文用 4 个测试用例 + 1 次反向变异验证,把这条接口的身份来源、授权顺序、Redis 更新顺序全部锁死,让”以后谁动了这块代码,CI 立刻红”。
🎧 文章导读
🎵 背景音乐

图1:身份来源、授权顺序、Redis 更新顺序三道契约锁点
一、结论先行
userParkSwitch 接口不会把前端传入的信息当作用户身份:
- 前端只提供目标
parkId。 - 后端通过
SecurityExtUtils.getCurrentLoginInfo()从当前登录 Token 对应的登录态取得userId。 - 后端用该
userId查询允许访问的园区,并检查前端传入的parkId是否在允许集合内。 - 未通过园区授权检查时,不会调用
UserSecurityUtils.enhanceLoginInfo(...)更新 Redis 登录态。 - 通过检查后,角色也按”Token 用户的
userId+ 目标parkId“重新查询,不会沿用其他园区角色。
因此,修改前端请求中的 parkId 只能表达”想切换到哪个园区”,不能伪造用户身份,也不能绕过后端园区授权校验。
这是源码级的安全契约,已经被单元测试 + 反向变异验证锁死。后续任何”看起来没事”的优化,都不能静默破坏这套契约。
二、被测代码与关键调用链
接口:GET /user/userParkSwitch/{parkId}
文件:
- Controller:
service-provider/admin-service/src/main/java/cn/csg/building/admin/controller/UserController.java - 自动化测试:
service-provider/admin-service/src/test/java/cn/csg/building/admin/controller/UserParkSwitchSecurityContractTest.java
关键调用顺序:
1 | SecurityExtUtils.getCurrentLoginInfo() |
这条调用链上,有 4 个绝对不能错的点:
- 从
SecurityExtUtils.getCurrentLoginInfo()取身份——不能从请求体或 header 取。 - 用
user.getUserId()查授权园区——不能用前端传的userId(前端根本不应该传)。 assertBizCondition(parkAllowed, ...)必须在enhanceLoginInfo之前。- 角色查询必须按
userId + parkId两个条件——不能只按parkId、不能只按userId。
只要这 4 点有任何一个被破坏,就是越权漏洞。下面用 4 个测试用例把每一点都钉死。
三、4 个测试用例
用例 1:用户身份必须来自当前 Token 登录态
目的:证明 userParkSwitch 第一步是调 SecurityExtUtils.getCurrentLoginInfo(),而不是从请求体里读用户。
测试断言:
1 | BaseUser user = SecurityExtUtils.getCurrentLoginInfo(); |
然后使用该登录用户的 userId 查询授权园区:
1 | listManageParksByUserId(user.getUserId()) |
结果:通过。
如果未来有人误改成 request.getParameter("userId"),测试立刻红。
用例 2:前端 parkId 必须经过授权检查
目的:证明执行顺序是”先校验 → 后更新 Redis”。如果反过来,就是经典的 TOCTOU 漏洞——未授权 parkId 也会先被写进登录态。
测试断言执行顺序必须是:
- 从 Token 登录态读取用户;
- 根据 Token 的
userId查询允许园区; - 执行
assertBizCondition(parkAllowed, ...); - **校验通过后才执行
enhanceLoginInfo(...)**。
结果:通过。未授权 parkId 会在 Redis 登录态更新之前被拒绝。
用例 2 的价值不只是”测通过”,更是”测顺序”。如果有人为了性能优化把
enhanceLoginInfo提到校验前面,授权拦截就失效了。测试不仅测行为,还测顺序。
用例 3:切换后的角色限定为 Token 用户和目标园区
目的:证明角色查询条件同时包含 userId 和 parkId,且都在 userRoleService.listByCondition(userRoleDTO) 之前设置。
测试断言:
1 | userRoleDTO.setUserId(user.getUserId()); |
两个条件必须在 userRoleService.listByCondition(userRoleDTO) 前设置。
结果:通过。
如果有人误改成只按 parkId 查角色,多园区用户切园区时会”继承”上一个园区的角色——这是隐蔽的越权。
用例 4:反向变异测试
目的:确认测试不是”假阳性”——即测试不仅要在当前实现下通过,还要在”故意搞坏实现”时失败。
反向变异测试(mutation testing)是验证测试有效性的关键手段。如果不管代码怎么改测试都绿,那这套测试根本没价值。
操作:临时将
1 | listManageParksByUserId(user.getUserId()) |
替换为非 Token 用户标识:
1 | listManageParksByUserId("front-end-user-id") |
执行结果:
1 | 必须使用 Token 中的 userId 查询授权园区 |
测试失败,命中预期断言。
随后已恢复生产代码并重新执行测试,恢复绿色。
反向变异让我们确信:这套测试是”真在测身份来源”,不是”测试自身写错了所以永远绿”。
四、最终执行结果
执行环境:
1 | JDK: E:\jdk |
执行命令:
1 | $env:JAVA_HOME='E:\jdk' |
最终结果:
1 | Running cn.csg.building.admin.controller.UserParkSwitchSecurityContractTest |

图2:正向通过 → 反向变异 → 期望失败 → 恢复 → 重测通过,闭环验证
五、测试边界与未做的事
本次是可重复执行的源码安全契约测试,并完成了反向变异验证。它证明当前代码的身份来源、授权顺序和 Redis 更新顺序不会在后续修改中被静默破坏。
本次没有做的事:
| 未做项 | 原因 | 如何补 |
|---|---|---|
| 真实浏览器 Token 对运行中的网关发请求 | 当前任务没有提供可用的测试账号、Token 和已启动的 gateway/admin-service | 补一组抓包测试 |
| 黑盒证据 | 同上 | 同一 Token 请求授权园区应成功;将请求中的 parkId 改为未授权园区应失败;确认 /user/info 中的当前园区未变化 |
源码契约 + 集成测试是互补的:源码契约覆盖”代码该长什么样”,集成测试覆盖”运行时是不是真的这样”。两者都做,才是完整的安全闭环。
六、经验总结
6.1 安全契约测试的三个关键设计
- 断言身份来源:用
SecurityExtUtils.getCurrentLoginInfo()还是request.getParameter("userId")?这是不同的安全等级,测试必须钉死前者。 - 断言执行顺序:先校验后写缓存 / 先校验后改角色——顺序错了整个授权就失效。测试要验证顺序,不仅验证结果。
- 断言条件完整:角色查询要
userId + parkId同时设置,缺一个就是越权入口。
6.2 反向变异测试不能省
很多团队写完正向用例就觉得”安全了”,但实际上:
- 测试覆盖了 100 行代码,可能只有 50 行被实际断言。
- 反向变异能告诉你”哪部分代码改了测试不会红”——那些”沉默的代码”才是风险。
这次我们做的反向变异只有一次,但只要做了一次,就证明这套测试是有效的。后续可以加更多变异点(比如把 enhanceLoginInfo 提到 assertBizCondition 前面),但单次的反向变异已经把”测试有效性”这件事钉死了。
6.3 安全测试不是黑盒专属
源码级的安全契约测试有几个优势:
| 优势 | 说明 |
|---|---|
| 不需要部署 | 在 CI 里就能跑,反馈快 |
| 不需要构造 Token | 直接调 SecurityExtUtils,mock 登录态即可 |
| 能定位到代码行 | 失败时直接定位到具体调用点,黑盒测试只能定位到接口 |
| 不会因为环境问题误报 | 不依赖 Redis、网络、网关 |
源码契约测试不能替代黑盒测试,但它是开发阶段就能跑、CI 阶段就能拦的安全防线。
6.4 推荐把这套测试接入 CI
1 | # .github/workflows/security-contract.yml |
任何对 UserController.userParkSwitch 的修改都会触发 CI,CI 失败就意味着越权风险。这比”上线后被安全部门扫出来”早了好几个迭代。
七、结语
越权漏洞最可怕的不是”被利用”,而是”没人知道它在”。一行 parkId 的改动、一次”看起来没事的优化”、一次性能重构——都可能让原本安全的接口变成越权入口。
源码契约测试 + 反向变异验证,把”安全不变量”显式地写进测试里。CI 跑得越勤,越权漏洞的存活窗口就越短。
一行 parkId 改不出越权——前提是有人用测试把这件事钉死。