Skip to content

同一份输入,放进 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 引擎。每一层都需要保持数据与语法的边界。

原始数据保留业务含义HTML 文本 / 属性编码固定结构,属性加引号JavaScript 序列化生成值,不拼代码URL 解析与白名单再交给属性编码HTML 解析器得到文本 / 属性JavaScript 引擎得到预期的数据值URL 解析器允许的协议与目标HTML脚本值属性后再解析
图 1 · 示意:同一份原始数据沿不同输出路径进入不同解析器;URL 的允许范围需要独立校验

图中 URL 有两道判断:属性编码保持 HTML 结构;协议与目标校验决定链接允许做什么。两者缺一,另一层不会自动补上。OWASP 的上下文编码指南将这些输出位置分别处理。

输出位置推荐处理需要额外检查的边界
普通 HTML 正文框架默认文本插值;DOM 更新用 textContent不能为了显示格式改成任意 HTML 插入
普通属性值固定属性名,给值加引号,用属性编码或框架绑定事件处理属性不属于普通文本属性
href、src 等 URL解析并校验允许的协议和目标,再用属性绑定参数分量编码不能替代整个 URL 的校验
JavaScript 数据优先独立 JSON 接口;必须内联时用框架的脚本序列化不把输入拼成代码、函数体或事件处理器
CSS优先把受限业务枚举映射到固定 class任意选择器、属性名、声明或 URL 不能交给通用 HTML 转义处理

编码应靠近输出点。同一份原始文字可能用于邮件、JSON 和 HTML;提前统一「转义一次」会丢失使用场景,还可能产生重复编码。富文本有意包含 HTML,需要按允许的标签、属性和 URL 规则清洗,再进入明确的 HTML 输出入口;清洗结果之后再被字符串拼接或其他库改写,仍要重新审查。普通文字则直接使用文本输出。DOM 安全输出方法也强调选择合适的写入接口。

二、模板保护的是指定的上下文 ​

实验把包含双引号、单引号、& 和尖括号的同一份文字放入四个位置。以下是实际编译运行模板的关键片段,省略了页面外壳与测试定位属性:

html
<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。开启策略时,响应头为:

http
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 且 HttpOnlyLax 且可被脚本读取
同源页面的 document.cookie看不到可见
同源 fetch 的服务端 Cookie 头收到收到
上述跨站顶层 GET 的 Cookie 头未收到收到

这里的跨站是两个不同主机名,均指向本机;不是在比较跨端口的同站请求。测试显式设置了 SameSite,没有测浏览器缺省行为,也没有覆盖跨站 POST、iframe 或第三方 Cookie 策略。

HttpOnly 限制脚本读取 Cookie,不阻止页面脚本发起携带 Cookie 的请求;SameSite 主要约束跨站携带,不能阻止同站页面里已经运行的脚本。真实会话还需要 HTTPS、适当的 Secure、作用域和生命周期,以及接口鉴权。Cookie 属性的定义见 Set-Cookie 文档。

审查页面时,可以从「数据的最后一个写入位置」开始,逐个核对文本、属性、URL、脚本、样式和富文本入口。接口跨源可读性另见浏览器的跨源限制,SQL 解释器的数据边界见参数绑定与结构白名单,秘密存储另见加密方式的使用边界。这些机制各管一个边界,不能互相代替。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权