一行 parkId 改不出越权:契约测试如何锁死 Token 身份来源

多园区切换接口 GET /user/userParkSwitch/{parkId} 能不能被前端伪造身份?改一行 parkId 就能越权切到其他园区吗?

这是多园区改造最容易出现的”看起来安全、其实没拦住”的安全漏洞。本文用 4 个测试用例 + 1 次反向变异验证,把这条接口的身份来源、授权顺序、Redis 更新顺序全部锁死,让”以后谁动了这块代码,CI 立刻红”。

🎧 文章导读

🎵 背景音乐

userParkSwitch 接口的安全契约锁点

图1:身份来源、授权顺序、Redis 更新顺序三道契约锁点

一、结论先行

userParkSwitch 接口不会把前端传入的信息当作用户身份:

  1. 前端只提供目标 parkId。
  2. 后端通过 SecurityExtUtils.getCurrentLoginInfo() 从当前登录 Token 对应的登录态取得 userId。
  3. 后端用该 userId 查询允许访问的园区,并检查前端传入的 parkId 是否在允许集合内。
  4. 未通过园区授权检查时,不会调用 UserSecurityUtils.enhanceLoginInfo(...) 更新 Redis 登录态。
  5. 通过检查后,角色也按”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
2
3
4
5
6
SecurityExtUtils.getCurrentLoginInfo()
→ Token 登录态中的 userId
→ listManageParksByUserId(user.getUserId())
→ 校验 target parkId 是否属于授权园区
→ 按 userId + parkId 查询角色
→ enhanceLoginInfo 更新 Redis 登录态

这条调用链上,有 4 个绝对不能错的点:

  1. 从 SecurityExtUtils.getCurrentLoginInfo() 取身份——不能从请求体或 header 取。
  2. 用 user.getUserId() 查授权园区——不能用前端传的 userId(前端根本不应该传)。
  3. assertBizCondition(parkAllowed, ...) 必须在 enhanceLoginInfo 之前。
  4. 角色查询必须按 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 也会先被写进登录态。

测试断言执行顺序必须是:

  1. 从 Token 登录态读取用户;
  2. 根据 Token 的 userId 查询允许园区;
  3. 执行 assertBizCondition(parkAllowed, ...);
  4. **校验通过后才执行 enhanceLoginInfo(...)**。

结果:通过。未授权 parkId 会在 Redis 登录态更新之前被拒绝。

用例 2 的价值不只是”测通过”,更是”测顺序”。如果有人为了性能优化把 enhanceLoginInfo 提到校验前面,授权拦截就失效了。测试不仅测行为,还测顺序。

用例 3:切换后的角色限定为 Token 用户和目标园区

目的:证明角色查询条件同时包含 userId 和 parkId,且都在 userRoleService.listByCondition(userRoleDTO) 之前设置。

测试断言:

1
2
userRoleDTO.setUserId(user.getUserId());
userRoleDTO.setParkId(parkId);

两个条件必须在 userRoleService.listByCondition(userRoleDTO) 前设置。

结果:通过。

如果有人误改成只按 parkId 查角色,多园区用户切园区时会”继承”上一个园区的角色——这是隐蔽的越权。

用例 4:反向变异测试

目的:确认测试不是”假阳性”——即测试不仅要在当前实现下通过,还要在”故意搞坏实现”时失败。

反向变异测试(mutation testing)是验证测试有效性的关键手段。如果不管代码怎么改测试都绿,那这套测试根本没价值。

操作:临时将

1
listManageParksByUserId(user.getUserId())

替换为非 Token 用户标识:

1
listManageParksByUserId("front-end-user-id")

执行结果:

1
2
3
必须使用 Token 中的 userId 查询授权园区
Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE

测试失败,命中预期断言。

随后已恢复生产代码并重新执行测试,恢复绿色。

反向变异让我们确信:这套测试是”真在测身份来源”,不是”测试自身写错了所以永远绿”。

四、最终执行结果

执行环境:

1
2
3
JDK: E:\jdk
Maven: E:\maven363
时间: 2026-07-13 11:00:00 +08:00

执行命令:

1
2
3
$env:JAVA_HOME='E:\jdk'
$env:Path='E:\jdk\bin;E:\maven363\bin;'+$env:Path
mvn -pl service-provider/admin-service -Dtest=UserParkSwitchSecurityContractTest test

最终结果:

1
2
3
4
Running cn.csg.building.admin.controller.UserParkSwitchSecurityContractTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 17.436 s

反向变异验证流程

图2:正向通过 → 反向变异 → 期望失败 → 恢复 → 重测通过,闭环验证

五、测试边界与未做的事

本次是可重复执行的源码安全契约测试,并完成了反向变异验证。它证明当前代码的身份来源、授权顺序和 Redis 更新顺序不会在后续修改中被静默破坏。

本次没有做的事:

未做项 原因 如何补
真实浏览器 Token 对运行中的网关发请求 当前任务没有提供可用的测试账号、Token 和已启动的 gateway/admin-service 补一组抓包测试
黑盒证据 同上 同一 Token 请求授权园区应成功;将请求中的 parkId 改为未授权园区应失败;确认 /user/info 中的当前园区未变化

源码契约 + 集成测试是互补的:源码契约覆盖”代码该长什么样”,集成测试覆盖”运行时是不是真的这样”。两者都做,才是完整的安全闭环。

六、经验总结

6.1 安全契约测试的三个关键设计

  1. 断言身份来源:用 SecurityExtUtils.getCurrentLoginInfo() 还是 request.getParameter("userId")?这是不同的安全等级,测试必须钉死前者。
  2. 断言执行顺序:先校验后写缓存 / 先校验后改角色——顺序错了整个授权就失效。测试要验证顺序,不仅验证结果。
  3. 断言条件完整:角色查询要 userId + parkId 同时设置,缺一个就是越权入口。

6.2 反向变异测试不能省

很多团队写完正向用例就觉得”安全了”,但实际上:

  • 测试覆盖了 100 行代码,可能只有 50 行被实际断言。
  • 反向变异能告诉你”哪部分代码改了测试不会红”——那些”沉默的代码”才是风险。

这次我们做的反向变异只有一次,但只要做了一次,就证明这套测试是有效的。后续可以加更多变异点(比如把 enhanceLoginInfo 提到 assertBizCondition 前面),但单次的反向变异已经把”测试有效性”这件事钉死了。

6.3 安全测试不是黑盒专属

源码级的安全契约测试有几个优势:

优势 说明
不需要部署 在 CI 里就能跑,反馈快
不需要构造 Token 直接调 SecurityExtUtils,mock 登录态即可
能定位到代码行 失败时直接定位到具体调用点,黑盒测试只能定位到接口
不会因为环境问题误报 不依赖 Redis、网络、网关

源码契约测试不能替代黑盒测试,但它是开发阶段就能跑、CI 阶段就能拦的安全防线。

6.4 推荐把这套测试接入 CI

1
2
3
4
5
6
# .github/workflows/security-contract.yml
- name: 安全契约测试
run: |
mvn -pl service-provider/admin-service \
-Dtest=UserParkSwitchSecurityContractTest \
test

任何对 UserController.userParkSwitch 的修改都会触发 CI,CI 失败就意味着越权风险。这比”上线后被安全部门扫出来”早了好几个迭代。

七、结语

越权漏洞最可怕的不是”被利用”,而是”没人知道它在”。一行 parkId 的改动、一次”看起来没事的优化”、一次性能重构——都可能让原本安全的接口变成越权入口。

源码契约测试 + 反向变异验证,把”安全不变量”显式地写进测试里。CI 跑得越勤,越权漏洞的存活窗口就越短。

一行 parkId 改不出越权——前提是有人用测试把这件事钉死。