每个后端工程师迟早都会收到那份「两大攻击」简报,通常是一串用来在合规表上打勾的缩写。下面是我希望早点看到的那一版。XSS(跨站脚本)说的是你往页面里放了什么:它把你自己的源变成了攻击者的执行环境。CSRF(跨站请求伪造)说的是浏览器往一个你本无意发出的请求上附带了什么:它把你的会话变成了攻击者的凭证。两者有关联,也常被混为一谈,而它们的修复方式差异很大——把其中一个做好,并不能替另一个兜底。输出编码干掉 XSS;SameSite cookie 加 CSRF token 干掉 CSRF。而你仍然得两个都上心的原因是:一个 XSS 会悄无声息地废掉你整套 CSRF 防御。
XSS 的三种类型
所有 XSS 都是同一个 bug——浏览器把不受信任的数据当成了代码——但数据落在哪里,决定了谁会中招、以及你要在哪里修。
反射型 XSS
载荷随请求而来、又随响应弹回:一个搜索结果页把 ?q=<script>…</script> 原样回显到页面里,就是经典例子。攻击者得先诱导受害者点一个精心构造的链接(钓鱼、短链、广告),所以利用它需要「投递」。修复点在 sink——按它落地的上下文来编码。
存储型 XSS
载荷被存了下来——一条评论、一个昵称、一个文件名——然后被之后每一个加载页面的人看到。它不需要针对某个受害者发链接,所以这才是危险的那种:一条被下毒的记录就是埋在每位读者脚下的地雷,包括管理员。要在出口修,也就是渲染时编码。
DOM 型 XSS
服务器根本看不到载荷。客户端 JavaScript 从一个攻击者可控的 source(location.hash、postMessage、document.referrer)读数据,再交给危险的 sink(innerHTML、eval、document.write)。因为 bug 完全活在浏览器里,服务端转义碰不到它。办法是让 source 远离 sink:用 textContent 和 URL API,而不是 innerHTML。
真正的修复:按上下文做输出编码
「对输出做编码」就是全部规则,而其中真正吃重的词是上下文(context)。HTML 有好几种解析上下文,每种对应不同的转义,所以一个值只有相对于它被插入的位置才是安全的:
- HTML 文本 ——
<h1>{{ name }}</h1>—— 编码&、<、>。 - HTML 属性 ——
<a title="{{ t }}">—— 编码&、<、>、",并且永远给属性加引号。 - URL ——
<a href="{{ url }}">—— 百分号编码,并对*协议(scheme)*做允许列表。 - JavaScript —— 在
<script>或on*处理器里 —— 用 JSON 编码器,而不是 HTML 转义。 - CSS —— 少见,但也是独立上下文;干脆别把用户数据往里面插。
落到实践上:让一个真正的模板引擎来做。
# 有漏洞 —— 一个字符串,四种上下文,没有哪一个编码器能同时适配。
from flask import Flask, request
app = Flask(__name__)
@app.get("/hi")
def hi():
name = request.args.get("name", "")
return (
f'<h1>Hi {name}</h1>'
f'<div class={name}>profile</div>'
f'<a href="/u?name={name}">me</a>'
f'<script>var user = "{name}";</script>'
)
# 已修复 —— 让模板引擎接管 HTML 与 JavaScript 两个上下文。
from flask import Flask, render_template
app = Flask(__name__)
@app.get("/hi")
def hi():
return render_template("hi.html", name=request.args.get("name", ""))
<!-- templates/hi.html —— autoescape 已开启;{{ }} 会对文本和属性编码 -->
<h1>Hi {{ name }}</h1>
<div class="{{ name }}">profile</div>
<a href="{{ url_for('me', name=name) }}">me</a>
<script>var user = {{ name | tojson }};</script>
同一个页面上用了两个不同的编码器:{{ name }} 做 HTML 编码,{{ name | tojson }} 序列化成 JavaScript。这两者的区别本身就是这堂课的核心。
为什么「转义错了上下文」会失败
人们常常抓一个辅助函数——通常是 HTML 转义——就以为它能通吃一切。它不能:
- URL。
href="{{ url }}",其中url = javascript:alert(document.cookie),它被完整地 HTML 转义了,点击时照样执行;冒号和字母都只是普通字符。只有对协议做允许列表才有用。 - 未加引号的属性。
<div class={{ name }}>,其中name = x onmouseover=alert(1),会注入一个全新的属性,因为 HTML 转义永远不会去掉那个空格。 - JavaScript 字符串。 把一个值 HTML 转义后塞进
<script>var x = "{{ name }}";</script>,实体在 script 块里并不会被解码——浏览器看到的是字面量",字符串就错了,于是你很想去|safe它,而那会把转义整个拿掉。
escape() 不是咒语;它只是针对某一个上下文的某一个编码器。让编码器对上 sink,当框架已经给这个 sink 提供了辅助函数,就用它,别自己手搓。
Content-Security-Policy 是安全带,不是刹车
就算编码做到完美,漏掉一个 sink、或者新加一个模板,就又是一个 bug。Content-Security-Policy 是纵深防御:一个告诉浏览器它只能从哪些来源执行脚本的响应头,于是即便你的转义失了手,被注入的 <script> 也会失败。它不能替代编码——一个需要 unsafe-inline 才能跑的 CSP 什么都保护不了——但它把「一个 XSS 就是在浏览器里执行脚本」变成「一个被拦截的请求和一份违规报告」。
# 上严格策略;先用 report-only,再把同一个字符串改成强制模式。
add_header Content-Security-Policy "default-src 'none';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
upgrade-insecure-requests" always;
真正干活的是那一行 script-src 'self',不带 unsafe-inline、不带 unsafe-eval:攻击者的内联脚本会被拒。如果应用必须输出内联脚本,就给每段一个每次请求生成的 nonce 并列出来——别用 unsafe-inline 把门打开。关于周边那些响应头、nginx add_header 的继承陷阱,以及怎么测,我在Web 安全响应头里讲得更细。
CSRF:你的 cookie 就是漏洞所在
Web 的认证模型是「浏览器会把你的 cookie 自动附带到发往某个站点的每一个请求上,不管这个请求是谁发起的」。这正是会话能工作的原因——也正是 CSRF 钻的空子。攻击者不需要读你的 cookie,他们需要你的浏览器把它发出去。evil.example 上一个表单自动提交 POST https://bank.example/transfer,浏览器因为这是一次普通的跨站请求,会很配合地附上会话 cookie,于是转账就成了。
有两点要内化:
- 它针对的是状态变更。 跨站读取数据会撞上同源策略;提交一个表单则不会。所以破坏力集中在动作上:转账、删除、改邮箱、改密码。
SameSite如今是浏览器的默认答案。 现代浏览器默认把 cookie 设为SameSite=Lax,这会拦下跨站POST上的 cookie——也就是最常见的 CSRF 形态。这个默认值省了很多代码;但你还是不该盲目依赖它。
SameSite cookie:一行就能修,但有前提
一个 SameSite cookie 声明了浏览器在什么时候可以把该 cookie 附带到跨站请求上:
Lax—— 在顶层导航(点击链接)时发送,但在跨站POST或子资源请求上不发送。这是会话 cookie 合适的默认值。Strict—— 任何跨站场景下一律不发送。最强,但它会打断那些从外部链接进来、却已经需要会话的流程(一个 OAuth 重定向会让用户处于登出状态)。None—— 处处发送,所以必须搭配Secure;当某个第三方确实需要你的 cookie 时才用。
# 会话 cookie:这些属性的重要性和它的值一样。
response.set_cookie(
"session",
token,
httponly=True, # JavaScript 读不到——限制 XSS 造成的损害
secure=True, # 绝不走明文 HTTP 发送
samesite="Lax", # 不附带在跨站 POST 上
max_age=60 * 60 * 24 * 7,
)
对多数应用来说,光靠 SameSite=Lax 就能覆盖经典的表单和 fetch 伪造。但它覆盖不了老浏览器、你不得不设成 None 的 cookie,也覆盖不了兄弟子域的情形——evil.yourdomain.example 可以发出同站请求。想要真正的保证,你还需要 token。
CSRF token:证明「意图」的那张牌
一个 CSRF token 是绑定到会话的随机值,被嵌入到攻击者读不到的页面里,并在每个状态变更请求上被要求携带。伪造请求带不上它,因为同源策略让攻击者读不到你的 HTML。登录时生成它、随会话存储、渲染进每个表单、XHR 时通过响应头发送、并用恒定时间比较。
import secrets
from hmac import compare_digest
def check_csrf(request, session):
sent = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
expected = session.get("csrf_token")
# compare_digest 避免比较本身产生计时侧信道。
if not sent or not expected or not compare_digest(sent, expected):
return False
return True
人们常忽略的一点:把 token 绑定到会话——一个对谁都有效的 token 不比没有更好——并且让它不可预测(secrets.token_urlsafe(32),而不是用户 ID 的哈希)。
为什么「我们用 JSON API」并不自动安全
这是个值得戳破的迷思。把请求改成 Content-Type: application/json,按 CORS 的说法确实会把它从「简单请求」变成「需要预检」,而带自定义内容类型的跨站 fetch 会触发一个你可以拒绝的 OPTIONS。但是:
- 表单照样能 POST。 HTML 表单只能发
application/x-www-form-urlencoded、multipart/form-data或text/plain——永远发不了 JSON。所以只要你的端点接受表单编码的请求体,或者你根本没真正强制要求 JSON 内容类型,预检这道墙就压根不在场。 text/plain是一条绕过路径。 一个enctype="text/plain"的表单能在不触发预检的情况下发出 JSON 形状的字节;一个宽松的解析器会照单全收。- CORS 是浏览器侧的读取规则,不是服务端的 CSRF 防护。
curl和其他服务器完全无视它。
真正能断言用户意图的只有 token,而且它很便宜。
一个 XSS 就能击穿 CSRF token,所以 XSS 是更大的那条鱼
下面这个相互作用,决定了你的优先级顺序。
走一遍:攻击者在你的源上找到一个 XSS sink。他们不需要偷 cookie——它是 httpOnly,很好——因为他们的脚本现在正以你的源身份在受害者浏览器里执行。它从页面里读出 CSRF token,如果你把 double-submit cookie 留成了可读就这么读,然后带上一枚有效 token 发出同源 fetch。你建的每一层 CSRF 防御,前提都是攻击者读不到你的页面。XSS 恰恰就是让他们读到页面的那个 bug。这就是为什么 XSS 要排在第一位、为什么 CSP 不可商量:一个 XSS 不是一个 bug,它是一把能卸掉你其余所有防御的万能钥匙——而且它还能直接外带数据,这是 CSRF 永远做不到的。
加固清单
- Cookie: 会话 cookie 上
httpOnly+Secure+SameSite=Lax(或Strict)。 - Content-Security-Policy: 严格、不带
unsafe-inline/unsafe-eval,确实需要的内联脚本用 nonce,报告干净之前一直用 Report-Only。 - 按上下文做输出编码: HTML、属性、URL、JS、CSS 各自用对编码器;保持模板自动转义开启。
- 校验输入是为了正确性,但别把它和输出编码混为一谈;发文件时带上
X-Content-Type-Options: nosniff。 - CSRF token 覆盖每个状态变更请求,绑定到会话,恒定时间比较,外加对
Content-Type的显式检查。 - 别自己手搓: 用框架的模板引擎、会话 cookie、CSRF 中间件和密码哈希。
XSS 和 CSRF 看起来像是一对,但它们是有排序的:先修 XSS,因为能一击废掉 CSRF 防护、并独自外带数据的就是它。用按上下文的输出编码、一个严格的 CSP,再加上 SameSite 配合 token 的 CSRF 防御,那两个最常把一个小应用变成事故的 bug 就被堵上了。它和我已经在跑的安全响应头以及服务器加固叠在一起,正好收口。