深入 Play 11:Twirl 类型、输出转义与静态资源缓存
同一段包含 img 和 script 的输入,作为 String 输出时,浏览器中的事件标记保持 none;包成 Html 后,标记变为 script,img。两条请求都成功编译并返回200。编译期类型检查与浏览器执行安全,检查的是不同问题。
资源缓存也需要检查实际产物:本例的 Assets.versioned 反向路由生成普通 lab.css 路径,生产响应为 max-age=3600,并没有文件摘要后缀。方法名中的 versioned 不能代替摘要生成插件和产物验收。
模板参数进入构建过程
Twirl 模板是生成 Scala 源码的输入。本篇冻结 Play 3.0.6 和 Twirl 2.0.7;累计工程的 contentTwirl.scala.html 声明:
1 | |
控制器调用生成的 views.html.contentTwirl.render(PROBE, raw)。参数、表达式和生成代码共同参与编译,因此模板并不是运行时随意填入字符串的文件。第01篇的生成源码可以辅助检查调用签名;实际编译成功才证明当前调用满足类型要求。
本次另建临时工程,只把 payload 参数类型改成 Int,保留模板表达式和其他源码。编译退出1,错误指向模板中的 @Html(payload):
1 | |
编译已经在模板阶段失败,没有继续到 Java 控制器调用的类型错误。本例证明 Html 构造表达式不接受这个 Int 参数;不能把日志改写成“Java render 调用报错”,也不能据此声称模板类型系统验证了输入可信性。
负例原始日志在 evidence/batch09-11/twirl-negative.log。恢复 String 后,共享工程 test stage 成功,累计19项 JUnit 通过。复现时使用临时副本,避免将故意错误的模板留在可运行工程里。
String 与 Html 的输出路径
模板在两种模式下输出同一个固定合成 payload:
1 | |
BaseScalaTemplate 的显示逻辑对 String 调用 format.escape,对已经属于目标内容类型的值直接输出。HtmlFormat转义文本中的特殊字符;Html 的原始内容构造路径不会额外执行输入净化。
所以 Html(payload) 是调用者对内容作出的信任决定。它没有检查 script 标签、事件属性或恶意 URL。类型满足 Html 与内容适合直接输出之间,仍有应用自己的信任边界。
| 输出位置 | 需要检查的条件 | 本例覆盖程度 |
|---|---|---|
| 普通 HTML 文本 | 元字符转义 | String 探针覆盖 |
| 带引号的普通属性 | 属性结构与转义 | 第10篇回填覆盖 |
| href 等 URL 属性 | 转义以及协议、目标约束 | 未用此探针证明 |
| 内联 JavaScript / CSS | 对应语法与数据编码 | 不应靠 HTML 转义推断 |
| 原始 Html | 可信来源或可靠净化规则 | 当前只演示危险输出 |
HTML 转义不会把 javascript: 协议变成允许的协议。向 script 中嵌入数据也不能仅按 HTML 文本的规则判断。实际页面应按输出上下文选择表达方式,动态数据尽量放入受控数据接口或普通 DOM 文本,不把任意输入转换为 Html。
浏览器中观察执行
固定 payload 使用失效图片的 onerror 与内联 script 各调用一次 window.recordTwirlProbe。模板静态代码定义该函数,将收到的事件写入 #probe-events,因此观测直接存在于页面 DOM。
1 | |
实验服务器运行 stage 产物,在本机回环端口监听。真实内置浏览器分别打开 /content/twirl?raw=false 和 raw=true,检查 rendered 子树与事件输出。普通字符串路径中,img、script 元素数量均为0,事件 none;原始 Html 路径中,两类元素各1个,事件 script,img。
静态观测函数在两种页面中都执行;被比较的是 payload 是否形成元素并触发调用,不是页面是否完全没有 JavaScript。截图与 browser-receipt.json 保存浏览器事实,JUnit 和 HTTP 字节检查分别补充编译与响应证据。
这个结果限定于当前浏览器、后端和配置。没有执行多浏览器或 CSP 策略矩阵,探针函数也不是输入净化器。CSP 可能限制某些执行路径,但不能据此省略输出处的信任检查。
Assets 路由与真实产物
累计工程把 CSS 放在 public/lab.css,routes 通过 Assets.versioned 提供服务。这里的 public 是 Play 源资源目录,与博客的 Hexo 生成目录 public 不同;源码包必须包含前者,才能在新检出中重建资源。
stage 的 assets jar 中,public/lab.css 与源文件字节一致。本次检查没有找到 .md5 文件,记录位于 stage-assets.json。当前构建没有添加摘要插件,因此反向路由仍指向 /assets/lab.css。
Assets.versioned会检查请求名与资源摘要元数据。满足摘要路径时才进入相应的积极缓存分支;普通文件名仍走普通资源路径。实验请求全零伪造摘要名得到404,没有被重写成真实 CSS。
| 真实请求 | 当前观测 | 可证明的事实 |
|---|---|---|
| DEV 普通 CSS | 200,Cache-Control: no-cache | 开发模式要求缓存复核 |
| PROD 普通 CSS | 200,public, max-age=3600 | 当前普通资源缓存期限 |
| PROD 携带响应 ETag | 304,正文为空 | 当前条件请求生效 |
| 伪造摘要 CSS 名 | 404 | 该伪造路径未命中资源 |
cacheControl 实现结合模式和配置选择缓存值。no-cache 允许存储,但要求在复用前验证,不能解释成“不允许存储”。max-age=3600 表示当前新鲜期;实验不证明代理或 CDN 已正确配置。
生产 CSS 正文为271字节。ETag 是实际响应中的不透明验证值,不应当作源文件的内容摘要格式契约。开发与生产值不同,也不意味着资源源文件不同;判断打包内容时应比较 jar 中的字节。
更新资源时的交付约束
普通路径保持不变而内容更新时,仍在新鲜期内的客户端可能复用旧内容。若要采用摘要路径,构建应实际生成摘要元数据,模板引用生成的路径,并验证服务器识别该文件名以及普通路径、摘要路径的不同缓存结果。
只修改响应头为一年缓存,会放大普通固定路径的更新延迟。资源构建、URL 引用、服务器缓存和发布后的旧资源保留,应作为一组可验证行为处理。
源码还需进入版本控制。本仓库根目录忽略 public/,会同时匹配 Play 子工程的资源目录;本篇将准确的 public/lab.css 文件纳入提交和下载包。忽略规则存在时,磁盘有文件、一次本地 stage 成功,不能证明新检出有同样资源。
重跑与练习
从第00篇下载累计工程,按 RUN.md 准备工具链,在 play-lab/ 中执行:
1 | |
两种运行模式各185请求通过,包含37组本批内容检查及此前请求回归。浏览器另行打开两条 twirl 路由,比较事件输出和 rendered 子树;负例在临时工程单独运行 compile。HTTP 输出包含 script 字符串只能证明响应字节,不能单独证明浏览器已经执行它。
反例题:把用户评论转成 Html 后,模板编译成功、服务器返回200,是否可以认定输出安全?这些结果没有验证评论内容的信任边界;本篇浏览器探针给出了编译和请求成功但执行了输入脚本的反例。
改动练习:在独立分支为资源增加摘要构建步骤,保留普通路径对照;重新记录资源 jar、反向路由、两类缓存头与 ETag 请求。检查改变 CSS 后摘要 URL 是否变化,不以插件已安装代替产物验证。


