同一段包含 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
@(payload: String, raw: Boolean)

控制器调用生成的 views.html.contentTwirl.render(PROBE, raw)。参数、表达式和生成代码共同参与编译,因此模板并不是运行时随意填入字符串的文件。第01篇的生成源码可以辅助检查调用签名;实际编译成功才证明当前调用满足类型要求。

本次另建临时工程,只把 payload 参数类型改成 Int,保留模板表达式和其他源码。编译退出1,错误指向模板中的 @Html(payload):

1
2
3
4
overloaded method apply ...
cannot be applied to (Int)
@Html(payload)
(Compile / compileIncremental) Compilation failed

编译已经在模板阶段失败,没有继续到 Java 控制器调用的类型错误。本例证明 Html 构造表达式不接受这个 Int 参数;不能把日志改写成“Java render 调用报错”,也不能据此声称模板类型系统验证了输入可信性。

负例原始日志在 evidence/batch09-11/twirl-negative.log。恢复 String 后,共享工程 test stage 成功,累计19项 JUnit 通过。复现时使用临时副本,避免将故意错误的模板留在可运行工程里。

String 与 Html 的输出路径

模板在两种模式下输出同一个固定合成 payload:

1
2
3
4
5
6
7
<section id="rendered">
@if(raw) {
@Html(payload)
} else {
@payload
}
</section>

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
2
3
<img id="twirl-payload" src="/assets/missing-probe.png"
onerror="window.recordTwirlProbe('img')">
<script>window.recordTwirlProbe('script')</script>

实验服务器运行 stage 产物,在本机回环端口监听。真实内置浏览器分别打开 /content/twirl?raw=false 和 raw=true,检查 rendered 子树与事件输出。普通字符串路径中,img、script 元素数量均为0,事件 none;原始 Html 路径中,两类元素各1个,事件 script,img。

普通字符串输出保留字面文本,事件标记为 none

原始 Html 形成元素并执行探针,事件标记为 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
2
3
bash sbtw test stage
python3 lab/content_checks.py --dev
python3 lab/content_checks.py

两种运行模式各185请求通过,包含37组本批内容检查及此前请求回归。浏览器另行打开两条 twirl 路由,比较事件输出和 rendered 子树;负例在临时工程单独运行 compile。HTTP 输出包含 script 字符串只能证明响应字节,不能单独证明浏览器已经执行它。

反例题:把用户评论转成 Html 后,模板编译成功、服务器返回200,是否可以认定输出安全?这些结果没有验证评论内容的信任边界;本篇浏览器探针给出了编译和请求成功但执行了输入脚本的反例。

改动练习:在独立分支为资源增加摘要构建步骤,保留普通路径对照;重新记录资源 jar、反向路由、两类缓存头与 ETag 请求。检查改变 CSS 后摘要 URL 是否变化,不以插件已安装代替产物验证。

上一篇:表单绑定与业务验证。下一篇:CompletionStage与控制流。源码与重跑基线:最小应用。