同一份输入,放进 HTML 和脚本需要不同处理
页面能正确显示引号和尖括号,只证明了一个输出位置的行为。把同一份数据放进属性、链接或脚本,浏览器会换一套语法解释它。
跨站脚本(Cross-Site Scripting,XSS)防御要追踪数据最后进入的解析器。数据来自表单、数据库还是另一个接口,不决定它能否安全输出;输出位置、框架使用方式和允许的业务值共同决定处理方法。
本文只讨论防御。配套实验使用 Thymeleaf 3.1.5.RELEASE、JDK 25.0.4.1 和 Playwright 1.63.0 的 Chromium 153.0.8010.12,在容器内运行本地页面;输入只有普通文字、引号和标点,不访问外部站点。实验检查数据保真、DOM 结构、策略执行与 Cookie 可见性,不构成对整站安全性的证明。
一、先标记输出位置
浏览器解析 HTML 后,还可能把属性交给 URL 解析器,或把脚本内容交给 JavaScript 引擎。每一层都需要保持数据与语法的边界。
图中 URL 有两道判断:属性编码保持 HTML 结构;协议与目标校验决定链接允许做什么。两者缺一,另一层不会自动补上。OWASP 的上下文编码指南将这些输出位置分别处理。
| 输出位置 | 推荐处理 | 需要额外检查的边界 |
|---|---|---|
| 普通 HTML 正文 | 框架默认文本插值;DOM 更新用 textContent | 不能为了显示格式改成任意 HTML 插入 |
| 普通属性值 | 固定属性名,给值加引号,用属性编码或框架绑定 | 事件处理属性不属于普通文本属性 |
href、src 等 URL | 解析并校验允许的协议和目标,再用属性绑定 | 参数分量编码不能替代整个 URL 的校验 |
| JavaScript 数据 | 优先独立 JSON 接口;必须内联时用框架的脚本序列化 | 不把输入拼成代码、函数体或事件处理器 |
| CSS | 优先把受限业务枚举映射到固定 class | 任意选择器、属性名、声明或 URL 不能交给通用 HTML 转义处理 |
编码应靠近输出点。同一份原始文字可能用于邮件、JSON 和 HTML;提前统一「转义一次」会丢失使用场景,还可能产生重复编码。富文本有意包含 HTML,需要按允许的标签、属性和 URL 规则清洗,再进入明确的 HTML 输出入口;清洗结果之后再被字符串拼接或其他库改写,仍要重新审查。普通文字则直接使用文本输出。DOM 安全输出方法也强调选择合适的写入接口。
二、模板保护的是指定的上下文
实验把包含双引号、单引号、& 和尖括号的同一份文字放入四个位置。以下是实际编译运行模板的关键片段,省略了页面外壳与测试定位属性:
<p th:text="${value}"></p>
<p th:title="${value}"></p>
<script th:inline="javascript">
window.safeValue = [[${value}]];
</script>th:text 生成文本,th:title 输出带引号的属性,JavaScript 内联处理则生成完整的 JavaScript 值,调用处不用再加一对字符串引号。Thymeleaf 3.1 的脚本内联说明解释了这种序列化。
浏览器读取后的结果如下:
| 场景 | 断言结果 |
|---|---|
| 读取正文文本 | 与原始文字相同 |
读属性的 getAttribute | 与原始文字相同 |
| 读 JavaScript 内联生成的变量 | 与原始文字相同 |
| 在普通脚本里套用 HTML 文本插值 | 得到的值与原始文字不同 |
将普通词组 alpha beta 仅做 HTML 编码,手工拼进无引号属性 | 属性值变成 alpha,DOM 还多出 beta 属性 |
最后一行不经过 th:title;它是刻意构造的错误 HTML 生成器,用普通空格说明结构已经变化。实验没有证明第四行必然执行外来代码,只证明 HTML 文本处理不能代替 JavaScript 值序列化。
URL 还有另一种边界。实验仅检查、不点击一个 javascript: 协议的无副作用链接:th:href 输出后,浏览器读到的协议仍是 javascript:。属性结构完整不代表协议满足业务要求。站外链接可以只允许业务需要的 https:;站内链接还应核对解析后的 origin、路径和重定向策略。不要只检查原始字符串是否以某个域名开头。
CSS 同样需要收窄输入面。例如「主题色」用枚举映射到预先定义的 class,比接收任意 style 文本更容易审计。这一项是设计建议,本实验没有运行 CSS 编码或富文本清洗库的验证。
三、CSP 用来限制可执行内容
内容安全策略(Content Security Policy,CSP)可以进一步约束脚本执行。实验在两个页面放置相同的无害脚本:一段没有 nonce,另一段带服务端为当前响应随机生成的 nonce。开启策略时,响应头为:
Content-Security-Policy: default-src 'none'; script-src 'nonce-<本响应随机值>'这是一行机制示意,不是可直接复制的站点配置。应用要为每个响应生成不可预测的新值,并将同一个值写入获准脚本的 nonce 属性;还要按实际资源配置其他指令。
| 页面策略 | 无 nonce 的脚本 | nonce 匹配的脚本 |
|---|---|---|
| 未设置 CSP | 执行 | 执行 |
| 上述 CSP | 未执行 | 执行 |
结果来自脚本是否设置页面变量的断言。实验没有验证 CSP 报告收集、第三方脚本或不同浏览器。上线前可先用报告模式核对必要资源,再逐步启用限制;报告模式本身不阻止执行。nonce 不能被固定在所有页面上,也不能通过添加 unsafe-inline 来掩盖已有的内联代码问题。CSP 文档说明了 nonce、报告模式及策略组合。
CSP 是额外一层限制。允许执行的脚本本身若把数据交给危险的 DOM 写入入口,仍须修正那个入口;不能用策略代替上下文处理。
四、Cookie 属性各自缩小不同的风险
实验服务设置两个演示 Cookie:一个有 HttpOnly; SameSite=Strict,另一个使用 SameSite=Lax。普通同源访问与从 localhost 页面跳往 127.0.0.1 的跨站顶层 GET,得到:
| 观察位置 | Strict 且 HttpOnly | Lax 且可被脚本读取 |
|---|---|---|
同源页面的 document.cookie | 看不到 | 可见 |
同源 fetch 的服务端 Cookie 头 | 收到 | 收到 |
| 上述跨站顶层 GET 的 Cookie 头 | 未收到 | 收到 |
这里的跨站是两个不同主机名,均指向本机;不是在比较跨端口的同站请求。测试显式设置了 SameSite,没有测浏览器缺省行为,也没有覆盖跨站 POST、iframe 或第三方 Cookie 策略。
HttpOnly 限制脚本读取 Cookie,不阻止页面脚本发起携带 Cookie 的请求;SameSite 主要约束跨站携带,不能阻止同站页面里已经运行的脚本。真实会话还需要 HTTPS、适当的 Secure、作用域和生命周期,以及接口鉴权。Cookie 属性的定义见 Set-Cookie 文档。
审查页面时,可以从「数据的最后一个写入位置」开始,逐个核对文本、属性、URL、脚本、样式和富文本入口。接口跨源可读性另见浏览器的跨源限制,SQL 解释器的数据边界见参数绑定与结构白名单,秘密存储另见加密方式的使用边界。这些机制各管一个边界,不能互相代替。
配套实验
- codesphere-labs/engineering/context-output-encoding:Thymeleaf 3.1.5、JDK 25.0.4.1、Chromium 153:模板输出的数据保真、无引号属性与 URL 协议、CSP nonce 与 Cookie 属性(验证记录)
参考资料