信任的代价:我眼中一部 XSS 攻防二十五年的演进史
“所有注入漏洞的本质,都是语义歧义——
同一段数据,在不同的解析上下文中,被赋予了截然不同的含义。
防御的本质,是在数据越过上下文边界的那一刻,消除这种歧义。”
时间线速览
|
|
序章:一个开放世界,以及它必须付出的代价(1993–1996)
1993 年,当第一个图形化浏览器 Mosaic 出现时,互联网还是静默的文档海洋。网页就是网页——文字、图片、超链接,仅此而已。Web 的设计哲学源于学术界的开放精神:信息应当自由流动,任何人都可以链接任何人的资源。
然后,1995 年,Brendan Eich 用了整整 10 天,为 Netscape 浏览器写出了 JavaScript。
浏览器从此不再是一个被动的文档阅读器,而变成了一台可编程的客户端机器。服务器可以下发一段代码,浏览器会执行它。Web 突然拥有了真正的交互能力——这是好事,也是所有麻烦的起点。
同源策略:第一道防线的诞生(1996)
Netscape 的工程师们很快意识到了危险:如果浏览器可以执行任意代码,那么一个恶意网站的脚本是否可以读取用户另一个标签页里的银行账户数据?
于是,同源策略(Same-Origin Policy,SOP) 随 Netscape 2.0 在 1996 年引入。规则非常简单:
不同"源"(Origin = 协议 + 域名 + 端口)的页面,不能读取彼此的数据。
|
|
SOP 非常成功地解决了一个问题:来自 evil.com 的脚本,无法读取你在 bank.com 页面上的 Cookie 或账户数据。
但 SOP 有一个生来就有的、无法消除的"宽容"。
SOP 的原罪:嵌入与读取的分裂
Web 要正常运转,就必须允许跨域引用。你的网站需要引用 CDN 上的 jQuery,Google 字体,友站的图片。如果 SOP 完全禁止跨域操作,互联网就分裂成了无数个孤岛。
所以 SOP 从设计之初就把跨域操作分成了两类,并用截然不同的规则对待:
| 操作 | 典型例子 | SOP 态度 |
|---|---|---|
| 嵌入(Embedding) | <script src>, <img>, <iframe> |
默认允许 |
| 读取(Reading) | fetch(), XMLHttpRequest |
默认禁止 |
逻辑是:"让你看“和”让你拿“是两码事。你可以把百度的 Logo 图片嵌入你的页面展示,但你的 JavaScript 不能读取这张图片的二进制内容。
这个设计在 1996 年是合理的折中。但没有人预见到,这个"宽容的嵌入"将成为几乎所有 Web 攻击的共同根源:
- CSRF 利用
<img src>跨域发请求 - Clickjacking 利用
<iframe>跨域嵌入页面 - XSS 利用
<script src>(或注入内联脚本)跨域执行代码
所有后来的安全机制——CSP、X-Frame-Options、SameSite Cookie——本质上都是在给这个"宽容的嵌入"打补丁。
这个"宽容的嵌入"还引发了一个有趣的历史插曲。
旁白:JSONP——用嵌入冒充读取的民间发明
SOP 禁止了跨域读取,但开发者们很快就发现了一个漏洞:<script src> 属于嵌入,是被允许的。而 <script> 标签加载的 JS 文件,浏览器会直接执行。
这意味着,如果一个跨域接口返回的不是普通 JSON 数据,而是一段可执行的 JavaScript 代码,那 <script src> 就能绕开 SOP 的读取限制,间接拿到数据。
这就是 JSONP(JSON with Padding) 的诞生逻辑,大约在 2005 年前后被广泛采用:
|
|
JSONP 很聪明,但它本质上是把 API 端点变成了一个"执行调用方指定函数名"的代码生成器。这个设计埋下了一颗安全隐患:调用者可以把任意 JavaScript 表达式作为"函数名"传进去:
|
|
b.com 返回:alert(document.cookie)//({"name": "Alice"}) ——直接执行了攻击代码。
CORS 规范(2009 年)的出现,正是为了给跨域读取提供一个安全的官方解法,从而让 JSONP 这种"民间补丁"退出历史舞台。但 JSONP 端点并没有真正消失——大量老接口至今仍在运行。这个遗留问题,我们在 CSP 章节里还会再次遇到。
SOP 防不住的事:注入执行
SOP 成功防止了"跨站读取”。但回到浏览器中间人的比喻:SOP 能保证包裹来自正确的地址,却无法保证包裹里的内容没有被人动过手脚。
这就是 XSS 的根本:不绕过 SOP,而是污染 SOP 信任的源。
攻击者不需要让 evil.com 的脚本去读 bank.com 的数据(SOP 会阻止)。他只需要让 bank.com 的页面里,悄悄混入了他写的脚本——然后浏览器会以为那是 bank.com 自己的合法代码,开开心心地执行。
|
|
SOP 完全失效了——不是被绕过了,而是压根没触发,因为这段脚本"合法地"拥有了 bank.com 的来源标签。
这个认识是理解全文的钥匙:
XSS 的本质不是入侵服务器,而是操纵浏览器这个中间人,让它误以为攻击者的代码是合法的本站代码。
第一章:语义歧义——一切漏洞的共同根源(2000)
1.1 命名:漏洞有了名字
1999 年到 2000 年间,微软安全响应中心收到了越来越多关于一类奇特攻击的报告——攻击者将脚本注入到受信任的网站,借助用户对该网站的信任,在用户的浏览器里执行恶意代码。
2000 年 1 月,微软工程师与 CERT 合作,在首份公开报告中为这类漏洞正式命名:Cross-Site Scripting,缩写 XSS(因为 CSS 已被 Cascading Style Sheets 占用)。
这个名字精准地描述了攻击结构:恶意脚本(Scripting)借助受信任的站点,完成跨站(Cross-Site)的攻击。
1.2 为什么会有 XSS?语义歧义
在进入历史案例之前,我们需要先理解一个贯穿本文所有漏洞和防御的底层概念。
早期 CGI 脚本的典型写法:
|
|
开发者的意图是:把用户输入的文本数据显示在页面上。
但 query 一旦被拼入 HTML 响应,浏览器就以 HTML 标记语言的语义来解析整个字符串。
如果 query = "hello",两种语义没有冲突。
如果 query = "<script>evil()</script>",冲突就出现了:
- 开发者的语义:这是一个搜索词,应当显示出来
- 浏览器的语义:这是一段 HTML,其中
<script>内是可执行代码
同一段数据,被开发者和浏览器赋予了不同的含义。开发者没有在数据离开"文本上下文"、进入"HTML 上下文"的那一刻,做任何形式的转换。这个"语义跨越"的瞬间,就是漏洞。
所有注入漏洞——XSS、SQL 注入、命令注入——本质上都是同一件事:
数据在切换解析上下文时,没有经过适当的转义,导致它被当作代码执行了。
理解了这一点,才能理解为什么防御必须关注"上下文",为什么黑名单总会失败,为什么"输出编码"是正确答案而不是"输入过滤"。
1.3 最早的 Reflected XSS:镜像攻击
最早被大量记录的 XSS 形态是反射型 XSS(Reflected XSS),得名于服务器将攻击者的 payload “反射"回给受害者的方式。
攻击路径极其简洁——攻击者只需要构造一个包含 payload 的链接,发给受害者:
|
|
一个典型的有漏洞的搜索页(PHP):
|
|
受害者点击,服务器将 payload 原样嵌回 HTML,浏览器执行,Cookie 被发往攻击者服务器。全程不需要任何服务器漏洞——服务器只是忠实地做了"把输入放回输出"这件事,而浏览器这个中间人,忠实地执行了它收到的全部内容。
这里有一个值得注意的细节:攻击者用 new Image().src= 来外传数据,而不是 fetch()。原因是图片加载属于"嵌入操作”,SOP 默认允许跨域——又一次利用了 SOP 的那个"宽容的嵌入"。
第二章:蠕虫纪元——XSS 第一次露出獠牙(2005)
2.1 Samy,以及那个改变一切的周末
2005 年 10 月 4 日,洛杉矶,19 岁的 Samy Kamkar 把一段精心构造的代码放进了他的 MySpace 个人资料页面。
他的动机相当单纯——他只是想涨粉。“也许一个月能涨 100 到 200 个朋友”,他想。把代码上传之后,他去睡觉了。
第二天早上醒来,他有 200 条好友请求。
一小时后,数字翻了倍。然后是指数级增长。每隔几秒,数以千计的账号被感染。仅仅 20 小时,超过 100 万个 MySpace 账号中招,其中 1,005,831 人被迫"加了 Samy 为好友",并在自己的主页上显示了 “but most of all, samy is my hero”。
MySpace 不得不紧急关闭整个平台进行清理。美国特勤局以《爱国者法案》相关条款展开调查。Kamkar 最终被判处 3 年缓刑、720 小时社区服务和 2 万美元赔偿金。
这是历史上传播速度最快的计算机蠕虫,被记入安全史册。
2.2 为什么 Reflected XSS 做不到这件事
要理解 Samy 蠕虫的威力,先要理解它和 Reflected XSS 的本质区别。
Reflected XSS 需要攻击者把含有 payload 的链接发给每一个受害者——一对一,主动触发。规模受限于攻击者能接触到的人数,而且用户往往能从 URL 中察觉到异常。
Samy 利用的是存储型 XSS(Stored XSS):恶意脚本被存入数据库,任何访问该页面的用户都会触发,无需点击任何特殊链接。
更关键的是,Samy 的脚本实现了自我复制。这使它成为了真正的蠕虫:每一个感染者都成为新的传播源,感染规模呈指数级增长,而不是线性增长。
蠕虫的核心逻辑:
|
|
步骤一揭示了 XSS 比 CSRF 危险得多的根本原因:脚本在受害者的域下运行,它就是"自己人",SOP 对它没有任何限制。CSRF 只能盲目地发请求,拿不到响应内容,因此无法获取 CSRF token;而 XSS 可以读取页面上的任何内容,然后用这些数据发出带着合法 token 的请求——CSRF 的所有防御,对 XSS 形同虚设。
这也正是浏览器作为"中间人"的危险所在:一旦攻击者的代码获得了浏览器的"信任执行权",它就能以用户的身份做几乎任何事情,而浏览器无从区分。
2.3 Samy 面对的工程挑战——绕过黑名单
Samy 的代码之所以精妙,不只是逻辑设计,还在于它成功绕过了 MySpace 的一系列过滤器。而这些绕过手法,恰好预演了下一个时代攻防博弈的全貌:
|
|
整个蠕虫代码里,没有出现一个 <script> 标签。Samy 绕过了 MySpace 的每一道黑名单过滤——这不是偶然,而是黑名单防御的系统性失败。
第三章:猫和老鼠——黑名单时代的必然崩塌(2003–2008)
3.1 直觉性的错误
面对 XSS,开发者的第一反应几乎都是:“把 <script> 过滤掉不就好了?”
这个直觉完全合理——在日常生活中,应对危险的方式就是识别并禁止已知的危险物。
但这个直觉忽略了一件事:我们在序章里说过,XSS 的根源是语义歧义,而不是某个特定的字符串。你无法通过穷举"危险字符串"来解决一个因"上下文切换"产生的问题,就像你不能靠封锁"剪刀"这个词来阻止所有的危险行为——刀、玻璃、牙齿,威胁的形态是无限的。
HTML 就是这样一门语言。它被设计成"容错优先"——浏览器的解析器遇到任何不规范的写法,都会尽力猜测意图并渲染,而不是报错停止。这意味着,通往"执行脚本"的路径,在 HTML 里是指数级多的,而任何黑名单都是有限的。
3.2 一场永远追不上的军备竞赛
第一代防御:
|
|
立刻被大小写绕过:<SCRIPT>alert(1)</SCRIPT>
或者双写(过滤一次后残余仍然有效):<scr<script>ipt>alert(1)</scr</script>ipt>
第二代防御:大小写不敏感的正则。
|
|
攻击者直接放弃 <script> 标签,转向 HTML 事件属性。HTML 里几乎每个元素都支持数十种事件属性,你没法把它们全过滤掉:
|
|
第三代防御:过滤所有 on* 事件属性。
攻击者转向 href 属性里的 javascript: 伪协议:
|
|
并且,浏览器在解析 href 时会先做规范化处理,再判断协议——这意味着以下所有写法,对浏览器都等价,但很多过滤器会放行它们:
|
|
这里再次是语义歧义:过滤器以"文本匹配"的语义来检查字符串,而浏览器以"URL 规范化后的语义"来解析属性值。两者的语义不同,过滤器看到的是无害内容,浏览器执行的是脚本。
3.3 黑名单失败的本质
这场军备竞赛可以无限持续下去,因为双方的"武器基础"不对等:
- 攻击者的武器是 HTML 的语义空间——标签、属性、事件、协议、编码方式的所有组合,理论上是无穷的。
- 防御者的武器是一个有限的黑名单——无论多详尽,总会有遗漏。
正确的思路的方向,只有两个:
一是转变视角:不去问"什么是危险的",而去问"什么是允许的"。把黑名单换成白名单——这就是后来富文本防御的基础。
二是在正确的位置做防御:不是在输入时过滤字符,而是在数据进入新的解析上下文时,消除语义歧义。这就是输出编码。
第四章:消除语义歧义——输出编码的正确姿势(2005–2010)
4.1 对症下药
回到语义歧义的根源:漏洞发生在数据被放入 HTML 上下文的那一刻,浏览器把数据中的特殊字符解析为了 HTML 语法符号。
输出编码的解法非常直接:在数据越过"文本上下文"进入"HTML 上下文"的边界时,把所有可能产生语义歧义的字符,转换为它们的 HTML 表示形式。
| 原始字符 | HTML 实体 | 效果 |
|---|---|---|
< |
< |
不再是标签的开始 |
> |
> |
不再是标签的结束 |
" |
" |
不再能闭合属性值 |
' |
' |
同上(单引号) |
& |
& |
防止实体注入 |
经过编码,攻击者的 payload 变成了浏览器无法执行的文本:
|
|
语义歧义被消除了:浏览器看到的是一串文字符号,而不是一个可执行的脚本标签。
4.2 上下文是关键:一套编码解决不了所有问题
这里有一个极易被忽视的陷阱,也是语义歧义的另一个体现:不同的输出位置有不同的语法规则,需要针对性的编码方式。
把数据放入 HTML 元素内容、放入 HTML 属性值、放入 JavaScript 字符串、放入 URL 参数——这是四个不同的"上下文",每个上下文有自己的解析规则,因此需要不同的编码策略:
|
|
最典型的上下文错误:
|
|
4.3 框架内建防御:“默认安全,显式危险”
手动在每个输出点做正确的上下文感知编码,既繁琐又容易遗漏。主流 Web 框架在这个阶段接过了这份责任,把正确的行为变成默认行为:
|
|
|
|
这个设计哲学被称为 “默认安全,显式危险”:危险的操作不是被禁止的,但需要开发者写出一个带有明显警示词(html_safe、safe、dangerouslySetInnerHTML)的特殊 API 才能触发。这强迫开发者在危险操作面前停下来,问一句自己:“我为什么要这样做?我确定这里的内容是安全的吗?”
4.4 HttpOnly Cookie:切断最直接的战果路径(2002)
2002 年,微软在 IE6 SP1 中引入了 HttpOnly Cookie 属性。原理极其简单:带有这个标志的 Cookie,JavaScript 代码无法通过 document.cookie 读取。
|
|
|
|
HttpOnly 不阻止 XSS 攻击本身,但它切断了 XSS 最直接的战果路径——窃取 session Cookie 来直接劫持用户登录状态。
然而,攻击者的字典里从不缺备用方案:
方案一:直接用已登录状态操作。Cookie 拿不到,但浏览器发起请求时会自动携带它。攻击脚本可以直接在受害者的浏览器里发起"修改密码"、“转账"等请求——Cookie 由浏览器这个忠实的中间人自动带上,攻击者完全不需要"看到"Cookie 的值。
方案二:键盘记录。在页面里注入键盘监听脚本,session Cookie 被保护了,但用户正在输入的新密码、信用卡号,依然暴露:
|
|
方案三:利用浏览器密码自动填充。注入一个不可见的 <input type="password">,等待浏览器密码管理器自动填充,再读取值。(现代浏览器已有针对此模式的防护,但这展示了攻击者的思路:保护一条路,他们就走另一条。)
所以,HttpOnly 是有价值的纵深防御层,而不是解决方案本身。它的意义是:在其他防线失守之后,限制攻击者能获得的最大战果。
第五章:DOM 的暗流——服务器看不见的战场(2005 年 7 月)
5.1 一篇让服务器失明的论文
2005 年 7 月,安全研究员 Amit Klein 向 Web Application Security Consortium 提交了一篇题为《DOM Based Cross Site Scripting or XSS of the Third Kind》的论文。他发现了一类特殊的 XSS:
攻击数据从来不经过服务器。
URL 的 #(fragment)之后的内容,是浏览器自己的事情,不会发送给服务器。但前端 JavaScript 代码可以直接读取它,然后写入 DOM。整个攻击在浏览器内部完成,服务器的日志干干净净,服务器端的任何输出编码对它完全无效。
Klein 将其命名为 DOM-based XSS,第三类 XSS。
5.2 一个必要的认知转折:浏览器是独立的第三方
理解 DOM XSS,先要纠正一个根深蒂固的直觉。
大多数人脑子里只有两个角色:我(用户),以及那个网站(服务器)。浏览器不过是中间那根透明的管道,忠实地传递内容,不会自作主张。
但这个模型在 DOM XSS 面前彻底失效了,因为它描述的不是现实。
现实是:
|
|
浏览器在执行谁的代码,它在那一刻就听谁的。 当你访问 bank.com,浏览器下载并运行 bank.com 的 JavaScript,这段代码可以读取页面上的一切,可以发起请求,可以改写 DOM。浏览器此刻是服务器的代理人,不是你的。
通常这没有问题。但 DOM XSS 揭示了更深一层的陷阱:不只是服务器可以驱动浏览器,浏览器里运行的前端代码本身,也可以从 URL、referrer 等地方读取攻击者放置的数据,然后自己完成整个攻击——服务器全程不知情。
想象浏览器是一个快递员,他验证了包裹来自 bank.com 的合法地址,然后拆开执行了里面的指令。但指令写的是:“请从你的快递单(URL hash)上读取备注,然后按备注内容行事”——而那个备注,是攻击者在路上偷偷写上去的。服务器发出包裹时,那个备注还不存在。
这就是 DOM XSS 的本质:浏览器被自己的前端代码"指挥”,读取了攻击者控制的数据,并把它当作代码执行了。服务器是这场攻击的局外人。
5.3 浏览器自导自演
一个经典的有漏洞的前端代码:
|
|
攻击者的链接:
|
|
服务器收到的请求:GET / —— 干干净净,没有任何异常。服务器返回完全正常的 HTML 页面。但是,浏览器(这个中间人)收到响应后,自己的 JavaScript 代码读取了 URL hash,把攻击者的 payload 写进了 innerHTML——浏览器自导自演,完成了整个攻击,服务器是一个彻底的旁观者。
5.4 源(Source)与汇(Sink):追踪数据流
理解 DOM XSS 的框架是追踪不可信数据从哪里来(Source)、流向哪里(Sink):
危险的来源(Sources)——攻击者可以控制的输入:
|
|
危险的汇点(Sinks)——将字符串解析并执行的 API:
|
|
防御规则非常简单,却经常被遗忘:
|
|
第六章:策略即防御——CSP 的诞生与演进(2010–2016)
6.1 从"净化"到"限制执行权"
到 2009 年,安全工程师们面对一个令人疲惫的现实:输出编码需要开发者在每一个输出点都做正确的事情。DOM XSS 更让服务器端的防御完全失效。只要有一处遗漏,攻击者就能突破。
Mozilla 的安全工程师们提出了一个全新的思路,把防御的视角从"数据"转向了"权限":
既然我们无法保证每一段代码都是干净的,那就告诉浏览器:只有来自白名单来源的脚本,才有执行的资格。
这是 Content Security Policy(内容安全策略,CSP) 的核心思想——它不阻止脚本被注入,而是阻止脚本被执行。
把之前的防御比作在门口搜查每一位进来的客人(输出编码),那 CSP 则是在场馆内部安装了监控系统:进来的人无论是谁,只要做出了不在白名单里的行为(执行未经授权的脚本),立刻被踢出去。
6.2 白名单运行机制
CSP 通过 HTTP 响应头告知浏览器执行规则:
|
|
浏览器收到这条指令后,就成了一个执行 CSP 规则的"安保人员"。当攻击者注入的脚本出现时:
|
|
浏览器的判断过程:
|
|
但这里立刻出现了一个现实问题:大量网站的正常代码本身就是内联脚本。如果 CSP 拒绝所有内联脚本,网站就残了。这就是 CSP 早期采用率极低的原因——它要么被设置得很宽松(允许 'unsafe-inline',等于白设置),要么破坏正常网站功能。
6.3 Nonce:给合法脚本发"通行证"
CSP Level 2(2015)引入的 nonce(一次性令牌)机制解决了这个问题。
这个机制的类比非常直观:想象一场演唱会。CSP 1.0 的规则是"只有本场馆的员工才能进后台"——但问题是,演出临时请了外援乐手,他们不是员工却需要进后台。nonce 的解法是:每场演唱会开始前,给所有被邀请的人发一个今晚专用的随机腕带。后台门口只认腕带,不问身份。
|
|
|
|
安全性的关键在于:每次请求的 nonce 值必须是随机且不可预测的。攻击者即使能注入 <script> 标签,也无法猜到当前这次请求的腕带编号,因此无法伪造一个有效的通行证。
6.4 strict-dynamic:信任的传递
nonce 机制还有一个实际问题:现代前端代码大量使用动态创建脚本(createElement('script'))做懒加载和代码分割。这些动态脚本无法预先注入 nonce,会被 CSP 拦截。
CSP Level 3 的 'strict-dynamic' 指令解决了这个问题:如果一段脚本本身拥有合法的 nonce,那么它动态创建的子脚本,也自动获得执行权,无需再经白名单审查。
|
|
这就像腕带制度升级为:拿到腕带的人,可以带自己的助手进来,助手不需要单独审核。
6.5 CSP 也不是无懈可击
CSP 是强大的,但不是银弹。它有一类经典的绕过场景:白名单域名本身存在可被利用的端点。
想象 CSP 白名单里有 https://trusted-api.com,而 trusted-api.com 上恰好还留着一个 JSONP 端点:
|
|
攻击者注入:
|
|
浏览器看到脚本来源是 trusted-api.com,在白名单内,于是放行执行——而实际执行的是攻击者指定的 callback 内容。
这说明了一个重要原则:CSP 的安全上限,等于它的白名单里安全性最差的那个域名。域名白名单模式(尤其是包含了 CDN、分析脚本等域名时)很难做到真正安全,这也是为什么基于 nonce 的策略要优于基于域名的策略。
第七章:富文本的噩梦——当防御遇上业务需求(2012–2018)
7.1 一个让所有防御方案"失灵"的场景
输出编码、CSP、HttpOnly——在大多数场景下,这套组合已经相当可靠。但有一类业务场景让它们全部"失灵":富文本编辑器。
博客编辑、邮件撰写、论坛发帖——用户有合法的需求来提交包含真实 HTML 标签的内容。<b> 加粗,<a> 链接,<img> 插图,这些是真正需要保留的功能。
如果对所有内容都做 HTML 实体编码,<b>加粗</b> 就会变成 <b>加粗</b> 被当作文本显示出来,富文本功能完全失效。必须允许一部分 HTML 通过——于是问题回到了起点:如何判断哪些 HTML 是"安全的"?
7.2 为什么正则做不到这件事
第三章里我们已经看到,用正则黑名单过滤"危险字符串"是不可靠的。在富文本场景里,正则还会遭遇一个更深层的困境:Mutation XSS(mXSS)。
mXSS 的核心机制是:某些 HTML 片段在经历"解析 → 序列化 → 再解析"的循环后,语义会悄悄发生变化,产生原本不存在的危险代码。
这是一个真实出现过的 mXSS 模式:
|
|
问题在于:服务器端的 HTML 解析器和浏览器的 HTML 解析器对 <noembed> 的处理逻辑不同。服务器认为 onerror= 是 img 的 src 属性值的一部分(文本),因此是安全的;而浏览器则可能以另一种方式解析 </noembed> 的边界,让 img onerror 成功逃逸出来。
这是语义歧义的终极形态:两个 HTML 解析器之间的语义歧义。字符串层面的过滤器对此毫无感知,因为它压根不真正"理解" HTML 的树形结构。
7.3 DOMPurify:用解析器来对抗解析器
2014 年,cure53 安全团队发布了 DOMPurify,它的核心洞见是:既然问题出在 HTML 解析的语义上,就用真正的 HTML 解析器来解决它。
正则过滤器就像一个不认字的门卫,只会在访客名单上按姓名逐字比对——只要改一个字,就能蒙混过关。而 DOMPurify 雇用了一个真正懂得阅读和理解访客身份的门卫:
|
|
|
|
必须单独处理的 href 协议——因为 href 属性本身在白名单里(链接是合法功能),但它的值可以是 javascript: 协议:
|
|
后端对应库:DOMPurify 是浏览器端的库,服务器端需要对应方案:
| 语言 | 推荐库 | 核心原理 |
|---|---|---|
| Java | OWASP Java HTML Sanitizer / Jsoup Safelist | 真正的 HTML 解析器 + 白名单 |
| Python | bleach(基于 html5lib) |
使用与浏览器同款的 html5lib 解析器 |
| Go | bluemonday |
白名单配置灵活 |
| Node.js | DOMPurify + jsdom | 与前端同一套逻辑 |
最佳实践是前后端双重净化,就像机场安检:登机口(后端存储前)的安检是必须的,登机时(前端渲染前)的再次确认是额外保障。后端是最后关卡,前端是纵深防御。
第八章:框架时代——把安全成本内化到工具里(2013–2020)
8.1 工程现实:安全不是唯一的指标
我们需要正视一个现实:为什么那么多有经验的开发者,仍然会写出有 XSS 漏洞的代码?
答案不是"他们不懂安全",也不是"他们不在乎安全"。
真正的现实是:开发者每天面对的是一组相互竞争的目标——
|
|
在功能赶 deadline 的压力下,element.innerHTML = data 比 element.textContent = DOMPurify.sanitize(data) 少打几十个字,逻辑更短,更容易通过 Code Review。
这是一个合理的成本权衡(cost trade-off),而不是道德问题。当"安全的做法"需要付出额外的工程成本,而不安全的做法更快、更简单,大多数人在大多数时候都会在无意识中选择后者。
这就是为什么安全工程的终极目标,不是培训开发者更有安全意识——而是让安全的做法成为最省力、最自然的做法。React、Vue、Angular 的出现,从这个维度上重新平衡了等式。
8.2 React:让安全的写法比危险的写法更短
React 的 JSX 在渲染任何变量时,默认对其进行 HTML 转义:
|
|
如果想要渲染原始 HTML,React 要求你使用一个不可能被无意识写出的 API:
|
|
这里的设计洞见是:把安全的路设计成省力的路,把危险的路设计成需要绕远的路。当危险操作需要额外的工程量,它自然地成为了 Code Review 中会被注意到的异常。
8.3 Vue 和 Angular 的相同哲学
Vue 的双大括号插值自动转义,v-html 则需要显式使用:
|
|
Angular 的 DomSanitizer 体系要求开发者用 bypassSecurityTrustHtml 这样的方法名来显式绕过安全检查——这个方法名里的每一个词都在喊"停下来想一想"。
8.4 框架安全的新前线:SSR 状态注入
框架的客户端模板安全极大降低了浏览器端的 XSS 风险,但服务端渲染(SSR)带来了一个新的语义歧义场景,它在HTML 解析器和 JSON 解析器之间产生冲突:
|
|
JSON.stringify 不会转义斜杠字符。攻击者输入 name = </script><script>evil(),结果:
|
|
浏览器的 HTML 解析器先于 JSON 解析器工作——它看到 </script>,就认为脚本块结束了,后面的 <script>evil() 成为新的脚本块被执行。
|
|
这个 bug 的根源,仍然是熟悉的老故事:两个解析器(HTML 和 JSON)对同一段字符串有不同的语义理解。
第九章:从 DOM Sink 断根——Trusted Types 与纵深防御(2019–今)
9.1 最后一公里问题
有了框架的模板安全、DOMPurify、CSP,XSS 已经变得比十年前难多了。但是还有一个"最后一公里"问题没有解决:
在某个历史遗留文件里、某次临时修复中,某个开发者写出了:
|
|
框架的模板安全管不到这里(这是直接 DOM 操作),DOMPurify 需要开发者主动调用(他没有调用),CSP 可能被这个站点的某个 JSONP 端点绕过。一处疏忽,前面所有防线都失去了意义。
2019 年,Chrome 79 引入了 Trusted Types,它把防御直接嵌入到了浏览器对 DOM API 的处理逻辑里。
9.2 Trusted Types:让危险 API 拒绝普通字符串
当通过 CSP 开启 Trusted Types 时,浏览器会改变 innerHTML 等危险 API 的行为:它们不再接受普通字符串,而只接受经过"可信处理器"(Policy)处理后的特殊对象。
如果把之前的防御层比作厨房里的各道检查(原料验收、烹饪规范、出菜审查),Trusted Types 则是在厨房出口放了一个只认官方食品安全认证标签的闸机——没有认证标签的菜,闸机不让出去,不管是谁做的。
|
|
|
|
Trusted Types 将"净化"从"开发者应该记得做"变成了"忘记做,代码跑不起来"——这是防御向编译期约束迈进的一步。
9.3 纵深防御:不是防线的叠加,而是失效的容忍
经过二十五年的演进,一套防御体系已经成形。但理解这套体系的关键,不是把它当作一个"把所有防线都设置好就万事大吉"的清单,而是把它理解为一种失效容忍架构:
|
|
|
|
没有哪一层是必须完美的。防御的强度来自攻击者需要同时突破所有层的难度。
第十章:CORS——SOP 的授权机制与正确实践
10.1 官方开的后门
现代 Web 开发几乎必然涉及跨域:前后端分离的 API、CDN、第三方统计脚本……这些都需要合法的跨域访问,但又与 SOP 的限制直接冲突。
CORS(Cross-Origin Resource Sharing) 是 SOP 的"官方授权机制"——它让服务器可以显式声明:“我允许来自这些特定域名的跨域读取请求”。
CORS 不是 SOP 的漏洞,但错误配置的 CORS 比没有 CORS 还危险:
|
|
10.2 简单请求与预检请求:CORS 机制里最容易被误解的设计
很多人以为 CORS 是在"阻止跨域请求被发送"。这个理解是错的——而且这个误解有真实的安全后果。
CORS 从来不阻止请求被发送。它只阻止响应被 JavaScript 读取。
浏览器在执行跨域请求时,会先判断这个请求属于哪种类型,然后采取完全不同的处理方式:
简单请求(Simple Request)——同时满足以下条件的请求:
- 方法是
GET、HEAD或POST - 请求头只包含浏览器默认头(如
Content-Type限于application/x-www-form-urlencoded、multipart/form-data、text/plain) - 没有自定义请求头
对简单请求,浏览器的处理是:直接发出去,然后根据响应头里有没有 CORS 授权,决定是否让 JavaScript 读取响应内容。
|
|
这就是 CSRF 攻击能奏效的根本原因。<form> 表单的 POST 提交是典型的简单请求,浏览器会直接把它发出去,并且附上 Cookie——CORS 对这个过程完全没有阻止作用。只要服务器收到请求就执行,伤害就已经发生,哪怕 JavaScript 后来读不到响应也无济于事。这就是为什么防 CSRF 需要 Token 或 SameSite Cookie,而不能依赖 CORS。
复杂请求(Preflighted Request)——不满足简单请求条件的请求,例如:
- 方法是
PUT、DELETE、PATCH Content-Type是application/json- 包含自定义请求头(如
Authorization、X-CSRF-Token)
对复杂请求,浏览器会先发一个 OPTIONS 预检请求,问服务器:“我打算从 https://app.com 向你发一个带 Authorization 头的 DELETE 请求,你允许吗?”
|
|
只有服务器明确回应"允许",浏览器才会发出真正的请求:
|
|
这个设计背后有一个深刻的安全考量:复杂请求往往是有副作用的操作(删除、修改),浏览器在执行前先征得服务器同意,能防止在服务器来不及判断的情况下造成不可逆的伤害。简单请求则被认为是"历史上已经广泛存在的跨域行为"(HTML 表单一直都能跨域 POST),贸然拦截会破坏大量已有的 Web 功能。
这个区分,再次体现了 SOP 设计之初"嵌入允许、读取禁止"的折中逻辑——对于早已存在的跨域发送行为,CORS 的策略是"允许发,但控制读",而非"彻底禁止"。
10.3 一个常见的混淆:CSP 与 CORS 是两条平行线
CSP 的 frame-ancestors 指令能控制"我是否可以被放入别人的 iframe",这似乎和 CORS 的跨域控制有些像。很多人因此混淆了两者,以为它们在做类似的事情。
其实它们控制的是完全不同的维度。一个简单的类比:
- CORS 是在控制"别人能不能来读我的信件"(跨域读取数据)
- CSP
frame-ancestors是在控制"别人能不能把我的照片贴进他的相册"(被 iframe 嵌入) - CSP
script-src是在控制"什么人说的话我才执行"(脚本执行来源)
三件事情,三条独立的规则,彼此平行,互不影响。
结语:没有银弹,只有体系
二十五年过去了,XSS 依然是 OWASP Top 10 的常客。
这不是因为防御技术不够成熟。这是因为 Web 的根本矛盾从未改变:
浏览器必须信任来自服务器的内容,
而服务器无法完全控制内容的纯净性——
因为内容的一部分,来自用户。
攻防博弈的循环也从未停止:新的业务需求(富文本、SPA、SSR、WebComponents)带来新的攻击面,攻击者发现并利用它,防御技术被创造出来,攻击者寻找防御的配置错误和边界……
但有一件事是确定的:这二十五年的所有攻击,都遵循同一个模式——语义歧义。同一段数据,在服务器眼中是纯文本,在浏览器眼中是可执行代码;在 HTML 解析器眼中是属性值,在 URL 解析器眼中是协议跳转;在服务端解析器眼中是安全片段,在客户端解析器眼中是危险逃逸。
这二十五年的所有防御,也都遵循同一个解法——在数据越过上下文边界之前,消除这种歧义。输出编码、白名单净化、CSP、Trusted Types,手段不同,但目标一致:让数据在任何解析上下文中,都只能作为数据被理解,而不能被误读为代码。
理解了这两点,XSS 就不再是一份需要背诵的漏洞清单,而是一个有内在逻辑的安全体系。攻守之道,均由此出。
附录:防御速查
| 场景 | 推荐方案 | 需要审查的危险写法 |
|---|---|---|
| 普通文本显示 | 框架模板 {{ }} 插值 |
innerHTML 直接赋值 |
| 富文本展示 | DOMPurify + 后端白名单净化 | 正则过滤 / 未净化直接输出 |
| 后端模板输出 | 框架自动转义(Django/Rails/Thymeleaf) | 手动字符串拼接 HTML |
| URL 参数展示 | URL 编码 + HTML 上下文编码 | 直接拼入 href |
| JavaScript 字符串嵌入 | JS 字符串转义 | 模板字符串直接插值 "${input}" |
| SSR 状态注入 | JSON.stringify + \u003c/\u003e 转义 |
直接 ${JSON.stringify(state)} |
| DOM 操作 | textContent 赋值 |
innerHTML 赋值不可信数据 |
| 脚本执行控制 | CSP nonce + strict-dynamic | unsafe-inline |
| 跨域 API | CORS 白名单 Origin 校验 | Access-Control-Allow-Origin: * |
参考
- Samy Kamkar,Samy 蠕虫技术解析:https://samy.pl/myspace/tech.html
- Amit Klein,《DOM Based Cross Site Scripting or XSS of the Third Kind》,WASC,2005年7月
- PortSwigger Web Security Academy,XSS 章节:https://portswigger.net/web-security/cross-site-scripting
- OWASP XSS Prevention Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Google Trusted Types 规范:https://w3c.github.io/trusted-types/dist/spec/
- DOMPurify 项目:https://github.com/cure53/DOMPurify