📑 本页目录(点开跳转)
08c · CSRF 与 XSS:两种攻击,两套完全不同的防线
⏱ 54 分钟 | ⭐ 一个借用你用户的凭证,一个直接变成你这个源的代码
🎯 一句话
CSRF 是别人借用你用户身上那份「浏览器自动带的」凭证,XSS 是攻击者的代码跑进了你这个源的内部——前者靠 SameSite 和校验 Origin 挡得住,后者一旦得手,同源策略就站到了他那边。
⭐ 这一章接着 08b · 同源策略与 CORS 往下讲。那一章立的地基是一句话——同源策略限制的是「读响应」,不是「发请求」——⭐⭐ 而这一章的两种攻击都是它的推论:CSRF 用的是「发得出」那一半,XSS 干脆钻到边界里面去。08b 第一节那张「三个洞」的地图,这一章走的是后两行。
🎣 一、CSRF(跨站请求伪造):请求发得出去,才是这件事的根
⭐ 中文全称叫「跨站请求伪造」,Cross-Site Request Forgery。攻击剧本只有三步:
- 用户在你的站登录过,浏览器里躺着一个有效的会话 Cookie
- 用户在另一个标签页打开了攻击者的页面(论坛、邮件里的链接、任何地方)
- 那个页面里有一个自动提交的表单
<form action="https://app.example.com/api/transfer" method="POST">——⭐ 浏览器按规矩给这一发带上了你站的 Cookie
⭐⭐ 为什么 CORS 挡不住它:回到 08b 第一节那句话——CORS 管的是读响应,而攻击者根本不需要读。 钱已经转了、文档已经删了、模型已经烧了一万个 token。响应长什么样他不关心。
⭐ 也正因为如此,第 8 章那张表里「Bearer 无 CSRF」不是玄学:Authorization 头是你的 JS 手动加上去的,攻击者的页面既拿不到你的 token(那是另一个源的存储),也没法让浏览器替他加这个头——⭐ 而且他一旦用 fetch 手动加,就触发预检(08b 第二节),预检过不了请求根本发不出去。自动带的凭证有 CSRF,手动带的没有,判据就这一条。
🧯 三条防线
| 防线 | 做什么 | ⚠️ 注意 |
|---|---|---|
⭐ SameSite Cookie 属性 |
Lax:跨站的 POST 不带这个 Cookie;Strict:跨站连 GET 都不带 |
🗓️ 默认值会变,见下面的框 |
⭐ 校验 Origin 头 |
会改数据的方法,检查 Origin 在不在白名单里 |
白名单和 CORS 那份是同一份,别抄成两份 |
| 双提交(double submit) | 后端下发一个随机值,同时放进 Cookie 和页面;提交时前端把它放进请求头,后端比对两处是否相等 | ⭐ 原理仍是那条:攻击者的页面读不到你这个源的 Cookie,所以填不出那个头 |
# Cookie 会话的第二道锁:写死 SameSite + 校验 Origin(pip install fastapi httpx)
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.testclient import TestClient
ALLOWED_ORIGINS = {"https://app.example.com"} # ⭐ 和 CORS 白名单是同一份,别抄两份
app = FastAPI()
def same_site_only(request: Request):
"""⭐ 只对会改数据的方法查;GET 不查(它本来就不该改数据)。"""
if request.method in ("GET", "HEAD", "OPTIONS"):
return
origin = request.headers.get("origin")
if origin is None: # ⚠️ 非浏览器客户端(curl / 服务端)没有这个头
return # 它们也带不了别人的 Cookie,交给鉴权管
if origin not in ALLOWED_ORIGINS: # ⭐ 只信白名单里的那几个字符串
raise HTTPException(status_code=403, detail="来源不对")
@app.post("/api/login")
def login(resp: Response):
resp.set_cookie("sid", "signed-token-here", # 真实值见第 8 章的验签凭证
httponly=True, # ⭐ JS 读不到 → XSS 偷不走
secure=True, # ⭐ 只走 HTTPS
samesite="lax", # ⭐ 别人的页面发 POST 时不带它
max_age=3600)
return {"ok": True}
@app.post("/api/transfer", dependencies=[Depends(same_site_only)])
def transfer():
return {"done": True}
if __name__ == "__main__":
c = TestClient(app)
r = c.post("/api/login")
print("Set-Cookie:", r.headers.get("set-cookie"))
for origin in ["https://app.example.com", "https://evil.example", None]:
h = {} if origin is None else {"Origin": origin}
print("Origin=%-26s → %d" % (origin, c.post("/api/transfer", headers=h).status_code))
实跑输出:
Set-Cookie: sid=signed-token-here; HttpOnly; Max-Age=3600; Path=/; SameSite=lax; Secure
Origin=https://app.example.com → 200
Origin=https://evil.example → 403
Origin=None → 200
⚠️ 注意第三行:没有 Origin 头时上面这版放行了——留给 curl 和服务端调用方。🗓️ 现代浏览器对跨站 POST 一律会带 Origin,所以更严的做法是「没有 Origin 就拒」,代价是所有非浏览器调用方都得跟着改。两种都合理,但你要知道自己选的是哪种,并写下来。
🗓️ 这一格里会腐坏的东西,单独放在这里:
SameSite的默认值、第三方 Cookie 的处置、以及「跨站」到底怎么算,各家浏览器在不同版本里给过不同答案,而且还在变。 ⭐ 所以别依赖默认值,永远显式写samesite=;要查默认行为,以你的目标浏览器当前文档为准。 ⭐⭐ 上面那三步攻击剧本和三条防线的原理不会变——变的只是「不写它的时候浏览器替你选了什么」。
⭐ 还有一层本板块白拿的防护:你的接口只收 application/json。HTML 表单发不出这个 Content-Type(它只有那三种),而用 fetch 设它就会触发预检。实测这个栈的行为——同样一份 {"msg":"hi"} 的 body,Content-Type: application/json 返回 200,换成 text/plain、application/x-www-form-urlencoded 或不带,全是 422,业务函数没执行。⚠️ 但别把它当唯一防线:哪天你为了「兼容」让后端也接受表单编码的 body,这层就无声无息地没了。
💉 二、XSS(跨站脚本):边界内部被塞进了代码
⭐ 全称 Cross-Site Scripting。它和 CORS、CSRF 那两件事的差别是质变的:
⭐⭐ XSS 成功的那一刻,攻击者的代码就成了「你这个源」的代码,同源策略从此站在他那边。 他读
localStorage、发同源请求、拿走页面上的一切——每一件在浏览器看来都完全合法,因为那确实是你这个源的脚本在动。
⚠️ 所以 HttpOnly 只是减损,不是防御:它让脚本偷不走 Cookie,但脚本根本不需要偷——它就在页面里,直接以用户身份发请求就行了。
三种经典形态:
| 形态 | 恶意内容存在哪 | 典型入口 |
|---|---|---|
| 存储型 | ⚠️ 存进了你的库,每个看到它的人都中招 | 用户名、评论、上传的文档 |
| 反射型 | 在 URL 里,服务端原样吐回页面 | 搜索结果页把关键词拼进 HTML |
| DOM 型 | 服务端全程没参与,是前端 JS 自己把不可信数据塞进了 innerHTML |
前端读 URL 参数直接渲染 |
⭐⭐ 第四条路径:AI 应用独有的存储型 XSS
它比上面三种绕一道弯,而且绕过的正是「我又没有用户生成内容」这个自我安慰:
用户 A 上传一份文档,里面藏着一段 HTML → 它被切分、embedding、进了向量库(第 7 章)→ 用户 B 提了个相关的问题,这一段被检索出来进了上下文 → 模型照着把那段 HTML 输出了 → B 的前端把模型输出当 Markdown 渲染 → B 的浏览器执行了它。
⭐ 注意这条链上的每一环都是「功能正常」:上传成功、检索命中、模型听话、Markdown 渲染。没有一步是 bug。
⚠️ 而且它跨用户、跨租户——第 8 章那套 WHERE tenant_id 挡的是「B 读到 A 的数据」,⭐ 挡不住「A 的数据经过模型转述后在 B 的浏览器里执行」,因为这一趟里数据是合法地被检索出来的。
🔍 XSS 到此为止:防御在别处,这里只给名字
⚠️ 本节不讲怎么防,因为板块里已经讲透了,重复一遍只会让两处不一致:
| 在哪 | 讲的是 | 对应哪种形态 |
|---|---|---|
| 09 · 最小可用前端 第二节 | ⭐ 永远 textContent,绝不 innerHTML |
对 DOM 型免疫——浏览器根本不会把它当标签解析 |
| ⭐⭐ 10 · 把流式接到界面上 第三节 | HTML 净化的四条最小判据 + 五种正则绕过形态 + 「净化必须是进 innerHTML 前的最后一步」 |
上面那条 AI 存储型路径 |
⭐ 分工是清楚的:这一章负责「这是什么攻击、有哪几种、为什么它比另两个严重」,第 10 章负责「怎么防、防到什么程度算够」。 ⚠️ 而这正是这一章存在的理由之一——09 和 10 两章的防御段落从头到尾没出现过「XSS」三个字母,读者学会了防御,却不知道自己防的东西叫什么、在别处怎么搜。
🧱 三、Content-Security-Policy:净化漏了一次的时候
⚠️ 这里必须用全称。 站内 CSP 这个缩写已经被占了两次,而且是完全无关的东西:《密码学与信息安全》里它是 CSPRNG(04 · 伪随机)和 Cryptographic Service Provider(18 · PKI 与证书),《不靠数据的 AI》里它是约束满足问题(13 · CSP 是什么)。三个 CSP 毫无关系,搜的时候一定会串。
Content-Security-Policy 是一个响应头,用来告诉浏览器「这一页允许加载和执行哪里来的东西」。⭐ 它的定位是纵深防御:假设你的净化有一天漏了一条,它让漏进来的那段脚本执行不了。
三条最小配置就能挡掉绝大多数落地形态:
| 指令 | 作用 |
|---|---|
default-src 'self' |
默认只允许同源资源 |
⭐ script-src 'self' |
不允许内联脚本和外部域的脚本——<script>alert(1)</script> 直接不执行 |
object-src 'none';frame-ancestors 'none' |
禁插件对象;⭐ 禁别人把你的页面套进 iframe(点击劫持) |
⚠️ 两个务实提醒:① script-src 'self' 会顺手打掉你自己的内联 <script> 和 onclick=——第 9 章那种单文件前端首当其冲,得先把脚本挪进独立文件;② 🗓️ 先用 Content-Security-Policy-Report-Only 跑一段时间看报告,再切成强制模式,否则上线当天页面直接白屏。
🗓️ 具体指令名和可用的取值一直在增补,以当前文档为准;不变的是那个定位——它是最后一道网,不是替代净化的。
🔄 换个栈怎么对应
| 概念(不会过期) | Python / FastAPI(本板块主栈) | Node(Express / Hono) | Go |
|---|---|---|---|
| Cookie 三属性 | set_cookie(httponly=, secure=, samesite=) |
res.cookie(..., {httpOnly, secure, sameSite}) |
http.Cookie{HttpOnly, Secure, SameSite} |
校验 Origin(本章那段) |
Depends(same_site_only) |
中间件 | 包一层 Handler |
| 现成的 CSRF 中间件(双提交 / 同步器令牌) | 第三方库 🗓️ | 第三方中间件 🗓️ | 第三方中间件 🗓️ |
| ⭐ 模板自动转义 | ⚠️ 裸 jinja2.Environment() 默认 autoescape=False;Starlette 的 Jinja2Templates 替你传了 select_autoescape()(按扩展名开)🗓️ |
各家模板引擎默认不同 🗓️ | html/template 按上下文自动转义;⚠️ text/template 不转义 |
| Content-Security-Policy 响应头 | 中间件里加一个响应头 | 安全头中间件 🗓️ 或手加 | 手加 |
| HTML 净化(模型输出进页面前) | 服务端净化库 🗓️ | 同左 | 同左 |
⭐ 这张表要看出的是:只有第一列和最后一列的「叫什么」在变。
SameSite/HttpOnly/Origin/Content-Security-Policy是 HTTP 和浏览器的规矩,不是任何框架的发明——换栈之后一个字都不会变。 ⚠️ 但第三、四行要单独看:CSRF 中间件和模板转义不是浏览器给的,是各家框架自己的东西——⭐ 默认开没开、保护了哪些方法和路由,必须自己确认一遍,「装了一个 CSRF 中间件」不等于「它生效了」。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ⭐⭐ 08b · 同源策略与 CORS | 本章的地基在那里:源怎么算、「同源策略限制读不限制发」、CORS 为什么是放宽不是收紧、预检怎么触发。⭐ 本章那句「攻击者手动加 Authorization 就会触发预检」用的就是那一节的规则 |
| ⭐⭐ 10 · 把流式接到界面上 | XSS 的防御在那一章第三节:净化的四条最小判据、五种正则绕过形态、「净化是进 innerHTML 前的最后一步」。⭐ 本章给攻击命名,那一章给防御落地 |
| 09 · 最小可用前端 | 那一章第二节「永远 textContent 绝不 innerHTML」是对 DOM 型 XSS 的免疫;⚠️ 反过来,本章第三节那条 script-src 'self' 会打掉它那种单文件前端里的内联 <script> |
| ⭐ 08 · 认证、会话与多租户 | 本章补的是那一章第三节那张表里剩下的两格:「Cookie 怕 CSRF」「Bearer 怕 XSS」。⚠️ 那一章第五节的 WHERE tenant_id 挡不住本章第二节那条 AI 存储型路径——数据是合法被检索出来、再由模型转述的 |
| 08d · OAuth2 与 OIDC | 08e · JWT | 前者的 state 参数是本章 CSRF 防线在 OAuth 流程里的那一个具体落点;⭐ 后者那张「token 存哪」的表只回答「存哪」——HttpOnly Cookie 怕 CSRF、localStorage 怕 XSS 全被读走,怕的就是这一章这两样 |
| 13 · 配置与密钥 | ⭐ 那一章的铁律「密钥绝不放前端,浏览器 →(你的鉴权)→ 你的后端 →(你的 key)→ 供应商」在 XSS 视角下更硬:一旦 XSS 得手,前端有的东西攻击者全有——所以前端什么都不能有 |
| ../智能体工程教程/15-安全-沙箱与提示注入.html | 本章第二节那条「文档里藏 HTML → 检索 → 模型转述 → 别人浏览器执行」是间接提示注入的一个具体落点,那一章讲这类攻击的全貌和防线 |
| 14 · 容器化与部署 | Secure Cookie 要求 HTTPS,而证书和反向代理在那一章;⭐ Content-Security-Policy 这类响应头也可以统一加在代理层 |
✅ 检查点
- CSRF 的中文全称是什么?攻击三步是什么?⭐ 为什么 CORS 挡不住它,而
Authorization: Bearer天然不受影响? - 防 CSRF 的三条防线各是什么?哪一部分属于「会过期、要以当前浏览器文档为准」的内容?
- ⭐ 本章那段代码里,
same_site_only为什么对GET直接放行?为什么没有Origin头时也放行?更严的做法是什么、代价是什么? - ⭐ 「接口只收
application/json」为什么白送了一层 CSRF 防护?实测里换成别的Content-Type会得到什么?⚠️ 为什么不能把它当唯一防线? - ⭐ XSS 成功之后,同源策略为什么反而成了攻击者的帮凶?
HttpOnly为什么只是减损? - XSS 的三种经典形态是什么?各自的恶意内容存在哪?
- ⭐⭐ AI 应用那条独特的存储型 XSS 路径是什么?为什么第 8 章的租户隔离挡不住它?防御该去哪一章找?
- Content-Security-Policy 的定位是什么?最小三条配置是什么?站内为什么不能写
CSP这个缩写? - ⭐ 上线 Content-Security-Policy 的两个务实提醒是什么?其中哪一个会直接影响第 9 章那种单文件前端?
👀 答案
- 跨站请求伪造(Cross-Site Request Forgery)。三步:① 用户在你站登录过,Cookie 还在 ② 他打开攻击者的页面 ③ 那个页面自动提交一个指向你站的表单,浏览器按规矩带上了你站的 Cookie。⭐ CORS 挡不住是因为攻击者根本不需要读响应——钱已经转了。⭐ Bearer 免疫是因为
Authorization是 JS 手动加的:攻击者拿不到 token,也没法让浏览器替他加;一旦手动加就触发预检(见 08b),过不了就发不出。自动带的凭证有 CSRF,手动带的没有。 - ①
SameSite(Lax让跨站 POST 不带 Cookie,Strict连 GET 都不带)② 校验Origin头(只对会改数据的方法查,白名单和 CORS 那份同一份)③ 双提交(随机值同时进 Cookie 和请求头,后端比对;原理是攻击者读不到你这个源的 Cookie 所以填不出那个头)。🗓️ 会过期的是SameSite的默认值、第三方 Cookie 处置、「跨站」怎么算——⭐ 所以永远显式写samesite=;攻击原理和三条防线本身不过期。 GET/HEAD/OPTIONS不查,因为它们本来就不该改数据。⚠️ 没有Origin头时放行,是留给curl和服务端调用方——它们也带不了别人的 Cookie,交给鉴权管(实测第三行Origin=None → 200)。🗓️ 更严的做法是「没有Origin就拒」(现代浏览器对跨站 POST 一律会带),代价是所有非浏览器调用方都得跟着改。⭐ 两种都合理,关键是知道自己选了哪种并写下来。- 因为 HTML 表单发不出
application/json(它只有x-www-form-urlencoded/multipart/form-data/text/plain三种),而用fetch设这个头又会触发预检。实测:同一份{"msg":"hi"}的 body,application/json返回 200,换成text/plain/ 表单编码 / 不带全是 422,业务函数没执行。⚠️ 不能当唯一防线:哪天为了「兼容」让后端也收表单编码的 body,它就无声无息地没了。 - ⭐⭐ 因为攻击者的代码变成了「你这个源」的代码:读
localStorage、发同源请求、拿走页面上的一切,在浏览器看来全都合法。⚠️HttpOnly只挡「偷 Cookie」,但脚本不需要偷——它就在页面里,直接以用户身份发请求就行。 - 存储型(⚠️ 存进了你的库,每个看到的人都中招:用户名、评论、上传的文档)、反射型(在 URL 里,服务端原样吐回页面:搜索结果页把关键词拼进 HTML)、DOM 型(服务端全程没参与,前端 JS 自己把不可信数据塞进
innerHTML)。 - ⭐⭐ A 上传的文档里藏 HTML → 进向量库 → B 提问时被检索出来进上下文 → 模型照着输出 → B 的前端渲染 Markdown → B 的浏览器执行。 ⭐ 链上每一环都是功能正常,没有一步是 bug。第 8 章的
WHERE tenant_id挡的是「B 直接读到 A 的数据」,⚠️ 挡不住这条,因为这里数据是合法地被检索出来、再由模型转述的。⭐ 防御在第 10 章第三节(净化四条判据 + 五种绕过形态)。 - 它是一个响应头,告诉浏览器这一页允许加载执行哪里来的东西,⭐ 定位是纵深防御——假设净化漏了一次,让漏进来的脚本执行不了;它是最后一道网,不替代净化。最小三条:
default-src 'self'、⭐script-src 'self'、object-src 'none'/frame-ancestors 'none'(后者防点击劫持)。⚠️ 不能写缩写CSP:站内它已经是 CSPRNG / Cryptographic Service Provider(密码学)和约束满足问题(不靠数据的 AI),三者毫无关系。 - ① ⚠️
script-src 'self'会顺手打掉你自己的内联<script>和onclick=——⭐ 第 9 章那种单文件前端首当其冲,得先把脚本挪进独立文件;② 🗓️ 先用Content-Security-Policy-Report-Only跑一段看报告再切强制,否则上线当天页面直接白屏。
🛑 可以停在这里
⚡ 走神救援
⭐⭐ 两种攻击都是 08b 那句地基的推论(同源策略限制「读响应」不限制「发请求」):CSRF 用「发得出」那一半,XSS 直接钻进边界内部。 CSRF(跨站请求伪造)三步:① 用户在你站登录过、Cookie 还在 ② 他在另一个标签页打开攻击者的页面 ③ 那个页面自动提交一个指向你站的表单,浏览器按规矩带上了你站的 Cookie。⭐ CORS 挡不住,因为攻击者根本不需要读响应——钱已经转了。⭐ Bearer 天然免疫:
Authorization是 JS 手动加的,他加不了,手动加又触发预检——自动带的凭证有 CSRF,手动带的没有。三条防线:SameSite(Lax跨站 POST 不带)、校验Origin(⭐ 只查会改数据的方法,白名单和 CORS 那份是同一份;实测坏 Origin 得 403,⚠️ 没有Origin头时那版放行,留给curl,更严是「没有就拒」)、双提交(随机值同时进 Cookie 和请求头,攻击者读不到你这个源的 Cookie 就填不出)。🗓️ ⚠️SameSite默认值和第三方 Cookie 处置都会变,所以永远显式写samesite=。⭐ 还白拿一层:接口只收application/json,实测换成text/plain/ 表单编码 / 不带全是 422,⚠️ 但别当唯一防线。XSS(跨站脚本)是质变的那一个:⭐⭐ 一旦得手,攻击者的代码就是「你这个源」的代码,同源策略从此站在他那边;⚠️HttpOnly只是减损——脚本不用偷 Cookie,它就在页面里直接以用户身份发请求。三种形态:存储型 / 反射型 / DOM 型;⭐⭐ AI 应用还有第四条:A 上传的文档藏 HTML → 进向量库 → B 检索到 → 模型照着输出 → B 的浏览器执行,⭐ 每一环都功能正常,没有一步是 bug,⚠️ 第 8 章的WHERE tenant_id挡不住(数据是合法被检索出来的)。⭐ 分工:这一章负责命名,防御在第 10 章第三节(净化四条判据 + 五种绕过形态),第 9 章的textContent对 DOM 型免疫。最后 Content-Security-Policy(⚠️ 不能写缩写,站内已被 CSPRNG 和约束满足问题各占一次)是纵深防御:default-src 'self'/script-src 'self'/object-src 'none',⚠️ 会打掉内联脚本,🗓️ 先用Report-Only跑一段再强制。
下一节 👉 08d-OAuth2与OIDC.md