alice 的签名 session 可以访问对象 1,但访问对象 2 得到 403。bob 的权限恰好相反。alice 订阅对象 1 的 SSE 后撤销权限,下一次事件检查使连接中断;bob 的 WebSocket 在撤销后的下一条消息上失败,客户端却收到 Close 1000。

HTTP 身份、对象访问权、连接终止和业务成功分别有自己的证据。Close 1000 不能覆盖服务端已经记录的授权失败;签名有效也不能替代一次对象级检查。

Play 3.0.6 的固定源码提交为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。Java 的 Security.AuthenticatedAction 与默认 Authenticator 先调用 getUsername;有值时向 request attribute 写入 USERNAME 后继续执行,没有值时返回默认 401。默认 getUsername 只读 req.session().get("username")。

这段 Action 没有解析密码,没有查询用户表,也没有读取 X-User。Cookie 到 session Map 的解码发生在更前面的框架边界。JWTCookieDataCodec.decode 通过格式化器解析 JWT 数据;签名异常分支记录警告并返回空 Map。默认认证随后看不到 username,才产生 401。

1
2
3
4
5
6
7
8
9
请求 Cookie 字节
→ 框架 session codec 校验、解码
→ session[username]
→ AuthenticatedAction: USERNAME attribute
→ 对象策略: 用户、对象、当前撤销状态
→ 读取 / 修改 / 输出事件

伪造 X-User ──×──> 默认 Authenticator 不读取这个字段
签名有效 ──不充分──> 当前用户有权修改给定对象

实验使用 JDK 21、Play 3.0.6、Pekko Streams 1.0.3。GovernanceController.login 是公开的合成测试入口,接受 alice 或 bob 后调用 addingToSession,让框架产生 session Cookie。它只在 governance.fixtureLoginEnabled=true 的专项配置中开放;默认应用缺少这个开关时返回 404,已由 JUnit 验证。该端点没有证明任何真实用户的身份,不能作为生产登录代码使用。

修改签名 Cookie 需要实际改变签名字节。历史负测只改 Base64url 最后一个字符,曾得到 200;某些字符变化只改变尾部未使用位,解码出的字节没有变化。最终脚本改签名段首字符,得到 401。输入在文本上不同,不足以证明密码学校验输入已经不同。

对象策略需要覆盖所有入口

有限对象策略保存在 app/authlab/ObjectPolicy.java:alice 对应对象 1,bob 对应对象 2,撤销集合最多两名用户;修改只增加合成计数,不连接数据库。列表按策略逐项过滤,详情和修改在给定 id 上重新校验,订阅在建立前也执行同一策略。

签名身份、对象检查与撤销后的再次检查

输入与操作 实测结果 能定位的边界
无 Cookie,或仅 X-User/X-Username 401 默认身份入口不采信这些头
改变 session 签名字节 401 网络请求经过 Cookie 验签与身份提取
alice / bob 列表 [1] / [2] 列表没有输出另一个用户的对象
两名用户读取自己的对象 / 对方对象 200 / 403 身份不直接授权任意 id
合法 CSRF token 下修改对方对象 403 CSRF 通过以后仍检查业务访问权
跨对象 SSE、WebSocket 403 长连接入口也执行对象策略

只保护详情页会留下列表泄露;只过滤列表会留下直接访问 id 的入口;只给修改加注解则不能约束一个已经建立的订阅。测试矩阵应以“资源操作”列出路径,而不是以 Controller 类的数量估算授权覆盖。

检查与修改的原子性也属于策略的一部分。下面的完整示例以同一 monitor 保护撤销、检查和计数变更。它采用 Java 8 语法,在本实验 JDK 21 上编译运行;没有自行实现 Cookie 或 JWT 算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public final class OwnedCounter {
private final String owner;
private boolean revoked;
private int changes;

public OwnedCounter(String owner) {
this.owner = java.util.Objects.requireNonNull(owner);
}

public synchronized boolean update(String principal) {
if (revoked || !owner.equals(principal)) {
return false;
}
changes++;
return true;
}

public synchronized void revoke() {
revoked = true;
}

public synchronized int changes() {
return changes;
}

public static void main(String[] args) {
OwnedCounter counter = new OwnedCounter("alice");
if (counter.update("bob") || !counter.update("alice")) {
throw new AssertionError("object policy");
}
counter.revoke();
if (counter.update("alice") || counter.changes() != 1) {
throw new AssertionError("revocation or mutation count");
}
System.out.println("PASS owner/revoke/mutation");
}
}

若先调用 allowed,退出同步区后再写数据,撤销可能发生在两步之间。上述计数器把检查和变更放在同一同步操作中,只能约束这一个进程内对象。迁移到数据库时,需要按事务或条件更新重新定义检查与写入的边界,不能认为 Java monitor 已覆盖其他实例和数据库连接。

已有连接上的权限变化

握手授权只说明建立连接那一刻满足条件。SSE 的后续事件和 WebSocket 的后续消息还会读取数据,权限可能已经改变。实验采用逐次检查:SSE 每个事件输出前检查,WebSocket 每条文本消息处理前检查;上限分别为 20 个事件或 20 条消息,连接最多运行五秒。

1
2
3
4
alice SSE:  身份+对象通过 → event1 → revoke完成 → event2前检查失败 → 终止
bob WS: 握手101 → before回显 → revoke完成 → after检查失败 → Close
↑
撤销操作的响应不是所有客户端停收的确认

SSE 的事件节流间隔为 150 ms。撤销请求完成后,脚本继续在原连接读取,观察到 ConnectionResetError,终态为 revoked_or_failed。WebSocket 先成功回显 object-2:before,撤销后再发送 after,收到 Close 1000;服务端终态同样为 revoked_or_failed。这证明后续消息没有按正常路径回显,不能把关闭码单独解释为业务成功。

逐次检查仍有时间窗口。SSE 检查通过后已经输出到下游的事件,可能在撤销之后才到达客户端;WebSocket 没有新消息时,也不会立刻触发本策略的下一次检查。要求主动切断时,需要管理连接与权限版本,并定义通知丢失、跨实例传播和在途数据的行为。这些机制没有在当前有限实验中实现。

把订阅检查从 map 内移到创建 Source 前,是一个可执行反例:连接建立后撤销不会再改变已捕获的判断。该改动可使初始握手测试仍然通过,却让原连接继续输出。因此撤销测试必须保持原 socket,不应通过新建连接后的 403 代替。

修改计数与重跑

真实网络矩阵执行四次允许的修改,其中包含 CSRF 与 CORS 对照。越权修改、撤销后的修改都被拒绝,最终计数仍为四。这个计数是合成业务状态,能够证明实验中的拒绝分支没有调用修改逻辑,不能证明真实数据库没有写入。

累计工程入口见第00篇。设置 JDK 21 的 JAVA_HOME 后,在 play-lab 目录执行:

1
2
bash sbtw clean test stage
python3 lab/governance_checks.py --evidence evidence/batch26-29/replay

脚本分别管理默认安全配置、关闭 CORS 联动绕过的配置、精确信任本地代理的配置,使用临时 loopback 端口并在 finally 终止自建进程。每次运行产生新随机密钥,之前的 Cookie 不应跨进程重用。

历史隔离证据为 evidence/batch26-29/isolated/run-final3/observations.json 的 authorization 节点、同目录 default.log,以及 junit/TEST-GovernanceTest.xml。该隔离基线共 37 项 JUnit 通过,含 3 项治理测试;这不是后来共享工程的累计数量。原始来源摘录在 source-excerpts.txt,精确源文件 SHA256 在 source-receipt.json。

共享接入后的累计验证见 evidence/batch26-29/shared-build.json:49 个不同 JUnit 方法全部执行,0 失败、错误或跳过,stage 成功,五个治理 class 与生产 jar 字节一致。shared-http/observations.json 保留当前工程的授权、安全、流终态与三个进程退出记录;原隔离结果继续作为历史证据。

改动练习:将 alice 的对象从 1 改为 2,更新列表、详情、修改、SSE 与 WebSocket 的预期。只修改列表断言应当留下失败项。第二个练习把撤销检查移动到连接创建前,验证原连接上的撤销场景确实失败,再恢复逐次检查。

需求 检查位置 仍需另外证明
确定请求身份 已验签 session 到认证 Action 真实登录入口如何认证
访问某个对象 每个业务操作的对象策略 多实例、数据库的写入原子性
权限变化影响订阅 每事件、每消息再检查 在途数据与主动切断延迟

上一篇:生命周期与后台任务。下一篇:Web安全与信任边界。