一行 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 用户和目标园区

目的:证明角色查询条件同时包含 userIdparkId,且都在 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 改不出越权——前提是有人用测试把这件事钉死。