深入 Play E08:国际化、内容协商与缓存隔离
同一个文档路由可以返回法文 JSON,也可以返回英文 HTML。这些响应拥有同一个资源入口,却不能共用一个只包含路由名的缓存键。本篇的错误缓存实验中,Bob 已通过自己的租户检查,仍然读到了 Alice 的英文结果;响应甚至带着 private, no-store,因为泄漏发生在应用内部缓存。
语言、媒体类型、身份和输出转义分别决定响应的不同部分。Play 提供语言选择与模板基础设施,应用仍要定义可接受的表示、缓存分区和授权顺序。
固定版本与证据范围
下载国际化与协商工程及实验记录,核对SHA256SUMS。解压后的 play-electives/e08-i18n 是独立应用;接入后重跑2项JUnit、stage与36次HTTP观察,16个应用及生成class与生产jar字节一致,进程退出143、缓存清空且端口关闭,见 evidence/e08/integration。
独立工程为 examples/play-electives/e08-i18n,固定 Play 3.0.6、Scala 2.13.15、sbt 1.10.7 与 JDK 21。源码依据 Play 提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。示例使用 record、var 和 List.getFirst(),其中后者需要 JDK 21,不适用于 Java 8。
两项 JUnit 已执行,production stage 已生成。验证脚本启动该产物,留下 36 次真实 HTTP 观察,覆盖语言优先级、q 值、406、401、403、缓存混用和 HTML 转义。原始证据位于 examples/play-electives/evidence/e08/isolated/;源码类字节与 stage JAR 的对应关系由 manifest 核对。页面构建与浏览器检查属于另外的交付层。
工程使用合成用户 Alice、Carol、Bob。身份端点无需密码就能签发实验 session,密钥固定,安全过滤器关闭。因此下文验证的是 session 完整性检查与租户判定,不能据此宣称已实现真实用户认证。所有服务仅绑定回环地址,无数据库依赖。
语言选择先确定候选顺序,再匹配支持集
配置 play.i18n.langs=["en", "fr", "zh-CN"] 定义支持的语言及默认回退顺序。conf/messages 保存默认文案,messages.fr 与 messages.zh-CN 保存对应翻译。Java 控制器注入 MessagesApi,再调用 preferred(request);选择结果属于当前请求,不应存成所有用户共用的可变全局语言。Play Java 国际化文档
固定版本的 DefaultMessagesApi.preferred 先读取请求 transient lang,然后读取语言 cookie,再拼接 Accept-Language 的候选列表。这个顺序来自实现,不是根据浏览器设置推测。Messages.scala,第 491–496 行
flowchart TD
T[请求 transient lang] --> C[PLAY_LANG cookie]
C --> A[Accept-Language 按偏好排序]
A --> L[候选列表与已配置语言]
L --> M[Locale.lookup 匹配]
M -->|找到| S[选定 Messages 语言]
M -->|未找到| F[首个已配置语言 en]
S --> R[读取语言文案与默认文案]
F --> R
这张图表示候选列表的先后关系,不表示遇到任意字符串就立刻停止。Langs.preferred 将候选转成 Locale.LanguageRange,调用 Locale.lookup 在支持集中匹配;未匹配时选择第一个配置语言,没有配置时才使用 Lang.defaultLang。例如配置支持 fr,请求 fr-CH 可以回退到 fr;仅请求不支持的 de,实验返回 en。固定 Langs 实现、JDK 21 Locale.lookup
| 请求条件 | 实际选择 | 观察点 |
|---|---|---|
无语言 cookie,Accept-Language: fr |
fr | greeting 为 Bonjour |
fr-CH,zh-CN;q=0.5 |
fr | 地区候选匹配已支持语言 |
| 只有不支持的 de | en | 首个已配置语言回退 |
| cookie 为 zh-CN,header 为 fr | zh-CN | cookie 优先于 header |
| 上述 cookie/header,再设置 transient en | en | 当前请求临时覆盖 |
| 下一次请求没有 transient | zh-CN | 临时覆盖没有改写 cookie |
语言回退与消息键回退也是两层行为。/probe 在法文请求中读取仅存在于默认文件的 only.default,实际得到 Default catalog;读取完全不存在的 absent.key,实际得到键名本身。选中了法文并不保证每个消息键都已有法文翻译,缺失键需要独立检测。
Locale 用于语言相关处理,不等于用户时区、付款币种或法律地域。实验只选择文案,没有测试日期、数字或复数规则。需要这些能力时,应把时区、币种和格式化策略作为显式输入,不能从一次语言匹配推出用户属性。
accepts 只能回答匹配,不能替应用选最优表示
Play 的 RequestHeader.acceptedTypes 解析 Accept;accepts(mimeType) 检查列表为空或存在某个匹配范围。固定 MediaRange.accepts 比较媒体类型和通配符,没有在该方法中检查 q 值。RequestHeader.scala、MediaRange.scala
真实 /probe 请求发送 application/json;q=0,text/html;q=1,acceptsJson 与 acceptsHtml 同时为 true。因此,按 Java 分支顺序写成“若 accepts JSON 则返回 JSON,否则尝试 HTML”,不能保证尊重客户端优先级,也不能把这个布尔值当成 q=0 排除结果。
实验将可提供表示固定为 JSON 和 HTML。协商分两步:先按媒体范围的具体程度确定每个表示的有效 q,再从 q 大于零的表示中选最大值。精确类型比 type/* 更具体,后者比 */* 更具体。相同 q 使用服务端稳定顺序,JSON 优先;全部不可接受返回 406。RFC 9110 §12.5.1
application/json;q=0,*/*;q=0.8 的关键在于具体程度。JSON 已被精确范围排除,不能再从通配符取得 0.8;HTML 没有更具体限制,因此可以取通配符质量。实际 HTTP 返回 HTML。
| Accept | 实际结果 |
|---|---|
缺失或 */* |
200,JSON |
application/*;q=0.7,text/*;q=0.3 |
200,JSON |
application/json;q=0.2,text/html;q=0.9 |
200,HTML |
application/json;q=0,*/*;q=0.8 |
200,HTML |
| 两种具体类型都为 q=0 | 406 |
| 只有 application/xml | 406 |
text/html;q=1.1 |
400 |
这里的解析器刻意采用窄协议:只接受简单媒体范围和一个可选 q 参数,最多三位小数;重复范围、其他媒体参数与超过 2048 字符的输入返回 400。text/html;charset=utf-8 本身可以是合法 HTTP 语法,但超出该实验合同,同样返回 400。这不是完整 RFC 解析器;上线前应选择成熟解析能力并明确参数匹配策略。
下面是工程中完整的 app/lab/Negotiator.java。它不依赖 Play,可以用 JDK 21 单独编译运行;同一份类也被 production 控制器调用。
1 | |
JUnit 验证具体范围覆盖通配符、零质量排除、空支持集、稳定平局以及输入边界。HTTP 验证再检查控制器把返回空值映射成 406,把协议限制映射成 400,而不是只验证一个纯函数。控制器还将重复 Accept 字段值合并后交给解析器;本次 HTTP 场景未单独发送多行 Accept,该分支没有端到端覆盖。
授权先于缓存,缓存键仍必须包含身份
文档端点从 Play 签名 session 读取实验身份,并从服务端固定映射取得 tenant 与 user。请求 /document/:tenant 时,先检查 session 是否有效,再比较身份租户与路径租户。语言 cookie、Accept-Language 和 transient lang 均不参与这个授权判定。
flowchart TD
Q[文档请求] --> I[验证 session 并取得身份]
I -->|缺失或无效| U[401]
I --> P[身份租户与路径租户比较]
P -->|不同| D[403]
P --> N[选择语言与媒体类型]
N --> K[构造 tenant user language media 键]
K --> C[应用内部缓存]
C --> O[表示正文 Content-Type Content-Language]
O --> H[Vary 与 private no-store]
P -. 错误分支仍经过授权 .-> B[仅用 document 常量键]
B -. 跨身份命中旧表示 .-> O
三个身份分别为 red/alice、red/carol、blue/bob。没有 session 的法文请求返回 401;Alice 请求 blue 租户,即使改成中文也返回 403;Bob 请求 red 同样返回 403。修改 Alice session cookie 的一个字符后,请求返回 401。该观察证明这次被篡改的 cookie 没有取得原身份,不代表所有身份攻击都经过测试。
Play session 的签名用于完整性校验,不提供字段保密。固定版本的 cookie codec 使用 JWT 解析与签名校验;业务代码仍需控制 session 中存什么、如何签发以及何时失效。实验身份端点跳过密码校验,因此不能作为登录实现使用。固定 Cookie codec 源码
正确缓存键为 (tenant, user, selectedLanguage, selectedMedia)。同一租户内的 Alice 与 Carol 也必须分开,因为正文包含用户名;只加入 tenant 仍然不够。语言和媒体类型用已经选定的规范值,不直接采用任意长度的原始请求头,以免相同表示因为不同拼写产生不必要的缓存条目。
| 场景 | 请求身份与语言 | 缓存返回 |
|---|---|---|
| 正确键 | Alice,en,JSON | red/alice,en |
| 正确键 | Bob,fr,JSON | blue/bob,fr |
| 正确键 | Carol,fr,JSON | red/carol,fr |
| 错误常量键首次写入 | Alice,en,JSON | red/alice,en |
| 错误常量键再次访问 | Bob,fr,JSON | red/alice,en |
| 错误常量键换媒体类型 | Bob,fr,HTML | 仍返回缓存中的 JSON |
错误分支保存在 cache=bad,是有意保留的反例。Bob 的租户检查通过的是 /document/blue,随后却从常量键读取 Alice 的旧结果。授权通过不意味着随后任意缓存命中都安全;缓存必须绑定授权后确定的数据范围。
本实验只有一类固定文档、三个身份、三种语言、两种媒体表示,正确缓存键空间最多 18 个,错误缓存只有一个。真实系统还需要资源 ID、数据版本、权限变化后的失效策略和容量限制。示例没有引入 TTL 或分布式缓存,不把有限集合实验当成生产缓存设计。
Vary 约束 HTTP 缓存,不能修复内部 Map
文档响应设置 Vary: Accept, Accept-Language, Cookie,因为媒体、语言和 cookie 都可能影响结果;同时设置 Cache-Control: private, no-store。实际响应还携带选定表示的 Content-Type 与 Content-Language。transient 语言通过查询参数传入,已经属于请求 URI 的变化。
Vary 告诉 HTTP 缓存哪些请求头参与匹配,不会改变应用中 ConcurrentHashMap 的键。no-store 也不会阻止控制器自己的 computeIfAbsent。因此错误缓存场景即使带着这些响应头,仍然发生内部数据混用。对于受身份影响的响应,仅增加 Vary: Accept-Language 远远不够。RFC 9110 §12.5.5
是否缓存公共翻译页与是否缓存个人文档,应由数据敏感性和一致性需求分别决定。这里选择 private/no-store 是实验的明确响应策略;没有运行 CDN 或反向代理,不能据本地响应头断言某个外部缓存产品的实际行为。
Twirl 的 String 与 Html 表达不同信任
语言切换只改变显示内容,不改变 HTML 信任边界。模板接收普通 String 时,Twirl 按 HTML 模板规则转义;显式传入 Html 则表示调用方已经把内容当作可直接输出的片段。把用户输入包进 Html 会绕过这层普通字符串转义。Twirl 转义说明
实验模板 document.scala.html 的关键输出为 @payload 与 @trusted。前者收到固定攻击样本 <img src=x onerror=alert(1)>,后者只接收源码中的 <strong>fixed trusted markup</strong>。真实 HTTP 正文保存为 rendered.html,其中出现:
1 | |
脚本同时断言正文不含未转义的攻击标签,并包含固定 strong 标签。这证明两个类型在本次 HTML 文本位置的不同输出;没有执行浏览器脚本,也没有覆盖 JavaScript、CSS 或 URL 上下文。翻译文件中的文案若包含外部可控片段,也不能仅因来源是 Messages 就转成可信 Html。
JSON 分支使用 Play 的 JSON 对象序列化,HTML 分支使用已编译 Twirl 模板,两者共享身份和语言选择。完整模板与控制器都参与 stage 编译,文章中的输出不是手写页面截图。
复现实验与失败记录
先将 JAVA_HOME 指向 JDK 21,将 PLAY_LAB_SBT_LAUNCHER 指向 sbt 1.10.7 launcher JAR。在仓库根目录执行:
1 | |
输出目录必须尚未存在,避免覆盖旧证据。verify.py 自行选择回环端口,启动 stage,运行有限请求,最后发送 SIGTERM。此次退出码为 143,日志出现 I18N_CACHE_CLOSED correct=0 flawed=0,停止后的连接测试确认端口关闭。正常完成前,正确缓存有 6 个实际条目,错误缓存有 1 个。
首轮构建使用了不适用的 withSession(String,String) 调用,编译失败;固定 Java API 要求 Session 或 Map 参数。修改为 withSession(Map.of(...)) 后,两项 JUnit 与 stage 均通过。build-01.log 保留失败,build-02.log 保存修复后的完整构建,不把第一次失败擦除后声称一次通过。
独立 Java 例子的运行命令如下,输出 PASS q=0 overrides wildcard; no acceptable representation:
1 | |
练习与尚未覆盖的边界
练习一:为当前解析器增加媒体参数支持,先定义 text/html;level=1 与 text/html 的匹配规则,再增加具体参数、通配符和 q=0 组合的单元测试。验收必须包含“更具体范围取低 q”的反例,不能简单把所有匹配项中的最大 q 当作结果。该扩展标记为 NOT_RUN。
练习二:将正确缓存替换为一个独立共享缓存,加入资源 ID 和权限版本。让同一用户的权限发生变化,再验证旧表示不可继续读取;保留 Alice、Carol、Bob 三者的交叉访问断言。仅测试不同 tenant 不足以证明同租户用户隔离。外部缓存、权限失效和 CDN 行为均为 NOT_RUN。
此外,本次未验证真实密码认证、完整 Accept 语法、多行 Accept 的 HTTP 输入、全部语言标签、日期数字格式化、浏览器执行和生产负载。已通过的范围是固定语言集与表示集中的选择、响应元数据、签名身份边界,以及可复现的内部缓存反例。
系列导航
- 系列起点:最小应用
- 上一篇:E07 虚拟线程与阻塞 I/O
- 实验源码:
examples/play-electives/e08-i18n
