🏠 总目录📚 本教程 08c · CSRF 与 XSS
📑 本页目录(点开跳转)

08c · CSRF 与 XSS:两种攻击,两套完全不同的防线

54 分钟 | ⭐ 一个借用你用户的凭证,一个直接变成你这个源的代码


🎯 一句话

CSRF 是别人借用你用户身上那份「浏览器自动带的」凭证,XSS 是攻击者的代码跑进了你这个源的内部——前者靠 SameSite 和校验 Origin 挡得住,后者一旦得手,同源策略就站到了他那边。

⭐ 这一章接着 08b · 同源策略与 CORS 往下讲。那一章立的地基是一句话——同源策略限制的是「读响应」,不是「发请求」——⭐⭐ 而这一章的两种攻击都是它的推论:CSRF 用的是「发得出」那一半,XSS 干脆钻到边界里面去。08b 第一节那张「三个洞」的地图,这一章走的是后两行。


🎣 一、CSRF(跨站请求伪造):请求发得出去,才是这件事的根

中文全称叫「跨站请求伪造」,Cross-Site Request Forgery。攻击剧本只有三步:

  1. 用户在你的站登录过,浏览器里躺着一个有效的会话 Cookie
  2. 用户在另一个标签页打开了攻击者的页面(论坛、邮件里的链接、任何地方)
  3. 那个页面里有一个自动提交的表单 <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/plainapplication/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 这个缩写已经被占了两次,而且是完全无关的东西:《密码学与信息安全》里它是 CSPRNG04 · 伪随机)和 Cryptographic Service Provider18 · 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 与 OIDC08e · JWT 前者的 state 参数是本章 CSRF 防线在 OAuth 流程里的那一个具体落点;⭐ 后者那张「token 存哪」的表只回答「存哪」——HttpOnly Cookie 怕 CSRF、localStorage 怕 XSS 全被读走,怕的就是这一章这两样
13 · 配置与密钥 ⭐ 那一章的铁律「密钥绝不放前端,浏览器 →(你的鉴权)→ 你的后端 →(你的 key)→ 供应商」在 XSS 视角下更硬:一旦 XSS 得手,前端有的东西攻击者全有——所以前端什么都不能有
../智能体工程教程/15-安全-沙箱与提示注入.html 本章第二节那条「文档里藏 HTML → 检索 → 模型转述 → 别人浏览器执行」是间接提示注入的一个具体落点,那一章讲这类攻击的全貌和防线
14 · 容器化与部署 Secure Cookie 要求 HTTPS,而证书和反向代理在那一章;⭐ Content-Security-Policy 这类响应头也可以统一加在代理层

✅ 检查点

  1. CSRF 的中文全称是什么?攻击三步是什么?⭐ 为什么 CORS 挡不住它,而 Authorization: Bearer 天然不受影响?
  2. 防 CSRF 的三条防线各是什么?哪一部分属于「会过期、要以当前浏览器文档为准」的内容?
  3. ⭐ 本章那段代码里,same_site_only 为什么对 GET 直接放行?为什么没有 Origin时也放行?更严的做法是什么、代价是什么?
  4. ⭐ 「接口只收 application/json」为什么白送了一层 CSRF 防护?实测里换成别的 Content-Type 会得到什么?⚠️ 为什么不能把它当唯一防线?
  5. ⭐ XSS 成功之后,同源策略为什么反而成了攻击者的帮凶?HttpOnly 为什么只是减损?
  6. XSS 的三种经典形态是什么?各自的恶意内容存在哪?
  7. ⭐⭐ AI 应用那条独特的存储型 XSS 路径是什么?为什么第 8 章的租户隔离挡不住它?防御该去哪一章找?
  8. Content-Security-Policy 的定位是什么?最小三条配置是什么?站内为什么不能写 CSP 这个缩写?
  9. ⭐ 上线 Content-Security-Policy 的两个务实提醒是什么?其中哪一个会直接影响第 9 章那种单文件前端?
👀 答案
  1. 跨站请求伪造(Cross-Site Request Forgery)。三步:① 用户在你站登录过,Cookie 还在 ② 他打开攻击者的页面 ③ 那个页面自动提交一个指向你站的表单,浏览器按规矩带上了你站的 Cookie。⭐ CORS 挡不住是因为攻击者根本不需要读响应——钱已经转了。⭐ Bearer 免疫是因为 AuthorizationJS 手动加的:攻击者拿不到 token,也没法让浏览器替他加;一旦手动加就触发预检(见 08b),过不了就发不出。自动带的凭证有 CSRF,手动带的没有。
  2. SameSiteLax 让跨站 POST 不带 Cookie,Strict 连 GET 都不带)② 校验 Origin(只对会改数据的方法查,白名单和 CORS 那份同一份)③ 双提交(随机值同时进 Cookie 和请求头,后端比对;原理是攻击者读不到你这个源的 Cookie 所以填不出那个头)。🗓️ 会过期的是 SameSite 的默认值、第三方 Cookie 处置、「跨站」怎么算——⭐ 所以永远显式写 samesite=;攻击原理和三条防线本身不过期。
  3. GET / HEAD / OPTIONS 不查,因为它们本来就不该改数据。⚠️ 没有 Origin 头时放行,是留给 curl 和服务端调用方——它们也带不了别人的 Cookie,交给鉴权管(实测第三行 Origin=None → 200)。🗓️ 更严的做法是「没有 Origin 就拒」(现代浏览器对跨站 POST 一律会带),代价是所有非浏览器调用方都得跟着改。⭐ 两种都合理,关键是知道自己选了哪种并写下来
  4. 因为 HTML 表单发不出 application/json(它只有 x-www-form-urlencoded / multipart/form-data / text/plain 三种),而用 fetch 设这个头又会触发预检。实测:同一份 {"msg":"hi"} 的 body,application/json 返回 200,换成 text/plain / 表单编码 / 不带全是 422,业务函数没执行。⚠️ 不能当唯一防线:哪天为了「兼容」让后端也收表单编码的 body,它就无声无息地没了
  5. ⭐⭐ 因为攻击者的代码变成了「你这个源」的代码:读 localStorage、发同源请求、拿走页面上的一切,在浏览器看来全都合法。⚠️ HttpOnly 只挡「偷 Cookie」,但脚本不需要偷——它就在页面里,直接以用户身份发请求就行。
  6. 存储型(⚠️ 存进了你的库,每个看到的人都中招:用户名、评论、上传的文档)、反射型(在 URL 里,服务端原样吐回页面:搜索结果页把关键词拼进 HTML)、DOM 型(服务端全程没参与,前端 JS 自己把不可信数据塞进 innerHTML)。
  7. ⭐⭐ A 上传的文档里藏 HTML → 进向量库 → B 提问时被检索出来进上下文 → 模型照着输出 → B 的前端渲染 Markdown → B 的浏览器执行。 ⭐ 链上每一环都是功能正常,没有一步是 bug。第 8 章的 WHERE tenant_id 挡的是「B 直接读到 A 的数据」,⚠️ 挡不住这条,因为这里数据是合法地被检索出来、再由模型转述的。⭐ 防御在第 10 章第三节(净化四条判据 + 五种绕过形态)。
  8. 它是一个响应头,告诉浏览器这一页允许加载执行哪里来的东西,⭐ 定位是纵深防御——假设净化漏了一次,让漏进来的脚本执行不了;它是最后一道网,不替代净化。最小三条:default-src 'self'、⭐ script-src 'self'object-src 'none' / frame-ancestors 'none'(后者防点击劫持)。⚠️ 不能写缩写 CSP:站内它已经是 CSPRNG / Cryptographic Service Provider(密码学)和约束满足问题(不靠数据的 AI),三者毫无关系。
  9. ① ⚠️ script-src 'self' 会顺手打掉你自己的内联 <script>onclick=——⭐ 第 9 章那种单文件前端首当其冲,得先把脚本挪进独立文件;② 🗓️ 先用 Content-Security-Policy-Report-Only 跑一段看报告再切强制,否则上线当天页面直接白屏。

🛑 可以停在这里

走神救援

⭐⭐ 两种攻击都是 08b 那句地基的推论(同源策略限制「读响应」不限制「发请求」):CSRF 用「发得出」那一半,XSS 直接钻进边界内部。 CSRF(跨站请求伪造)三步:① 用户在你站登录过、Cookie 还在 ② 他在另一个标签页打开攻击者的页面 ③ 那个页面自动提交一个指向你站的表单,浏览器按规矩带上了你站的 Cookie。⭐ CORS 挡不住,因为攻击者根本不需要读响应——钱已经转了。⭐ Bearer 天然免疫Authorization 是 JS 手动加的,他加不了,手动加又触发预检——自动带的凭证有 CSRF,手动带的没有三条防线SameSiteLax 跨站 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

打卡记录保存在你的浏览器里,首页能看到总进度