📑 本页目录(点开跳转)
08b · 同源策略与 CORS:浏览器在替谁防谁
⏱ 54 分钟 | ⭐ CORS 是【放宽】同源策略的机制,不是收紧的
🎯 一句话
浏览器默认只信任「同一个源」,而 CORS 是你主动把这条边界放宽的那道门 —— 它决定的是「浏览器让不让那段 JS 读响应」,不决定「请求打不打得到你的服务器」。
上一章(08 · 认证、会话与多租户)那张 Cookie / Bearer 对照表里,「跨域要配 CORS」占了一格,但那一格只有结论没有过程。这一章把它底下的东西补出来。 ⭐ 那张表里另外两格(「Cookie 怕 CSRF」「Bearer 怕 XSS」)在下一章 08c · CSRF 与 XSS。
🧭 一、先立心智模型:浏览器在替谁防谁
源(origin)= 协议 + 域名 + 端口,三段字符串全等才算同源。以 https://app.example.com/chat 为基准:
| 对方 | 同源? | 为什么 |
|---|---|---|
https://app.example.com/api/x |
✅ | 路径不算,只看前三段 |
http://app.example.com |
❌ | 协议不同 |
https://api.example.com |
❌ | ⚠️ 子域不同也是不同源,「同一家公司」不是判据 |
https://app.example.com:8443 |
❌ | 端口不同(https 默认 443) |
⭐⭐ 然后是这两章最重要的一句,后面每一节都是它的推论:
⭐⭐ 同源策略限制的是「读响应」,不是「发请求」。 跨源的请求一直都发得出去,浏览器只是不把响应内容交给发起它的那段 JS。
这不是漏洞,是先后顺序造成的:<img src=...>、<script src=...>、<link>、<form action=...> 从 Web 第一天起就是跨源的,同源策略是后来加的,加的是「读不到」这一层,动不了「发得出」那一层——向后兼容不允许。
⭐ 三个洞就长在这句话的三个方向上:
| 一句话 | 被利用的是什么 | 在哪一章 | |
|---|---|---|---|
| CORS | 你主动开的一道门 | 门开太大(配置) | ⭐ 本章第二节 |
| CSRF | 别人借用你用户的凭证发请求 | 「发得出」那一半 + 浏览器自动带 Cookie | 08c |
| XSS | 攻击者的代码变成了你这个源的代码 | ⚠️ 同源策略本身——它开始保护攻击者 | 08c |
⭐ 这张表先看一眼就行,它是两章共用的地图:这一章走第一行,下一章走后两行。
🔍 本板块的默认架构里,你其实一个 CORS 都不需要:第 1 章用
FileResponse("index.html")把页面直接从后端发出去,第 9 章第六节用StaticFiles挂static/目录——页面和接口同源。 ⚠️ 需要 CORS 的那一刻,是第 9 章那张「上框架买进什么」表里第二行成真的时候:静态文件单独构建、单独托管,于是页面在app.example.com、接口在api.example.com。别在还同源的时候就先把 CORS 配上——配了就是白白开了一道门。
🔓 二、CORS:它是【放宽】同源策略的机制,不是收紧的
这是最常见的误解,而且方向正好反了:很多人以为「配了 CORS = 加了一层防护」。⭐ 实际上不配 CORS 才是最严的状态(没有任何跨源页面能读你的响应),配了才开始有源被放行。
⚠️ 先看一个反直觉的实测:被 CORS「拒绝」的请求,业务代码照样执行了
# 三种 CORS 配置,用 TestClient 当场看它回了什么头(pip install fastapi httpx)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
EVIL = "https://evil.example"
hits = []
def probe(title, **cors):
app = FastAPI()
app.add_middleware(CORSMiddleware, **cors)
@app.post("/api/chat")
def chat():
hits.append(1) # ⭐ 副作用:证明业务函数真的跑了
return {"ok": True}
c = TestClient(app)
pre = c.options("/api/chat", headers={ # ⭐ 手动模拟浏览器的预检
"Origin": EVIL,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type"})
hits.clear()
real = c.post("/api/chat", headers={"Origin": EVIL}) # ⭐ 真正的那一发
print(title)
print(" 预检 %s %s | Allow-Origin=%s | Allow-Credentials=%s"
% (pre.status_code, pre.text[:24],
pre.headers.get("access-control-allow-origin"),
pre.headers.get("access-control-allow-credentials")))
print(" 实发 %s | Allow-Origin=%s | ⭐ 业务函数执行了 %d 次"
% (real.status_code, real.headers.get("access-control-allow-origin"), len(hits)))
probe("A allow_origins=['*'] + allow_credentials=True ← 危险",
allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
probe("B allow_origins=['*'] + allow_credentials=False",
allow_origins=["*"], allow_credentials=False,
allow_methods=["*"], allow_headers=["*"])
probe("C 白名单 + allow_credentials=True ← 正确",
allow_origins=["https://app.example.com"], allow_credentials=True,
allow_methods=["POST"], allow_headers=["authorization", "content-type"])
这一段真跑过,原样输出(FastAPI 0.141 / Starlette 1.4,🗓️ 版本会变、行为多年没变):
A allow_origins=['*'] + allow_credentials=True ← 危险
预检 200 OK | Allow-Origin=https://evil.example | Allow-Credentials=true
实发 200 | Allow-Origin=https://evil.example | ⭐ 业务函数执行了 1 次
B allow_origins=['*'] + allow_credentials=False
预检 200 OK | Allow-Origin=* | Allow-Credentials=None
实发 200 | Allow-Origin=* | ⭐ 业务函数执行了 1 次
C 白名单 + allow_credentials=True ← 正确
预检 400 Disallowed CORS origin | Allow-Origin=None | Allow-Credentials=true
实发 200 | Allow-Origin=None | ⭐ 业务函数执行了 1 次
⭐⭐ 先看最后一列:三种配置下,业务函数都执行了 1 次。C 那行的预检明明返回了 400 Disallowed CORS origin,可真正那一发 POST 依然跑进了函数、返回了 200 和完整的响应体,只是没有 Access-Control-Allow-Origin 头。
⭐⭐ CORS 决定的是「浏览器让不让那段 JS 读这个响应」,不决定「请求打不打得到你的服务器」。 它不是访问控制。访问控制是第 8 章那套「身份只能来自已验签的凭证 + 租户条件由句柄拼」——⚠️ CORS 配得再严,也挡不住
curl和任何非浏览器客户端,它们根本不看这些头。
🧯 什么时候会多出那个 OPTIONS:简单请求 vs 预检请求
浏览器把跨源请求分两类。只要三条全部满足就是「简单请求」,直接发;⭐ 有一条不满足,浏览器就先发一个 OPTIONS 去问一声,这一发叫预检(preflight):
| 条件 | 简单请求要求 |
|---|---|
| 方法 | 只能是 GET / HEAD / POST |
| 请求头 | 只能是安全名单里那几个(Accept / Accept-Language / Content-Language / Content-Type 等);⚠️ 任何自定义头都出局 |
Content-Type |
只能是 application/x-www-form-urlencoded / multipart/form-data / text/plain |
⭐ 本板块的主路径两条都踩:第 10 章第二节那个 fetch,头里同时有 'Authorization': 'Bearer ' + getToken()(自定义头)和 'Content-Type': 'application/json'(不在三种之内)——跨域部署时,每一次对话之前浏览器都会先发一个 OPTIONS。
⚠️ 由此产生一个特别容易误诊的症状:接口用 curl 打得通,浏览器里却报 CORS 错误,而你的服务端日志里连一条 POST 都没有。原因是它死在预检那一步,POST 压根没发出来——⭐ 去日志里搜 OPTIONS,不是搜 POST。
预检这一问一答用四组头(上面输出里 A 的预检就是这么回的):
| 头 | 方向 | 管什么 |
|---|---|---|
Origin |
请求 | 我是谁 |
Access-Control-Request-Method / -Headers |
请求 | 我打算用什么方法、带什么头 |
Access-Control-Allow-Origin |
响应 | ⭐ 只有它决定 JS 能不能读到响应 |
Access-Control-Allow-Methods / -Headers |
响应 | 允许的方法和头。⚠️ 自定义头没列进来 = 预检不过 |
Access-Control-Allow-Credentials |
响应 | 允不允许这一发带 Cookie |
Access-Control-Max-Age |
响应 | 预检结果缓存多久(上面实测是 600,即 10 分钟内不再问) |
⭐ Starlette 的三条 400 文案就是三张诊断单,照着看就知道漏配了哪一格:Disallowed CORS origin(源没在白名单)/ Disallowed CORS method(方法没列)/ Disallowed CORS headers(头没列,⚠️ 加了个 X-Tenant-Id 就会撞上)。
💀 * 配 allow_credentials=True:看起来最正常的一个致命配置
规范里有一条硬规定:浏览器不接受「Access-Control-Allow-Origin: *」和「带凭证」同时成立。于是框架面临一个选择——是报错,还是让它「能用」?⚠️ Starlette 选了让它能用:把请求里的 Origin 原样回显。
上面 A 那两行就是证据:配的是 *,回的是 Access-Control-Allow-Origin: https://evil.example 外加 Allow-Credentials: true。💀 更狠的是 Access-Control-Allow-Headers 也照抄——我另外测过一发,请求里写一个现编的 x-anything-i-want,响应里就原样出现 x-anything-i-want。
💀 这个坑的形状:你以为你配的是「一个谁都能读、但不带身份的公开 API」,实际配出来的是「任意站点都可以带着用户的 Cookie 访问你的接口并读走响应」——正是同源策略存在的全部理由。而且:
- 响应里看不到
*,你看到的是一个具体的、看起来很正常的域名 - 自测永远是绿的——你用自己的页面测,回显的就是你自己的源
- 没有任何报错、告警、指标会动
⭐ 这就是第 16 章安全那节「CORS 不是 *」那条检查项的由来,它给的验证动作是用一个你不认识的 Origin 去打预检:
curl -i -X OPTIONS -H "Origin: https://evil.example" \
-H "Access-Control-Request-Method: POST" 你的域/api/chat
⭐ 判据是「回来的 Access-Control-Allow-Origin 既不是 *、也不是 evil.example」——只看到「不是 *」就放心,正好落进这个陷阱。
正确配法只有一句:allow_origins 写具体的源清单(上面 C 那种),别写 *,别用「https:// 开头就放行」这类前缀匹配——https://app.example.com.evil.test 也是 https:// 开头。
🗓️ 顺带一个缓存上的细节:回显具体源时 Starlette 会带上
Vary: Origin(实测 A / C 有,B 那种真*没有)。⚠️ 中间有 CDN 或缓存代理时它是必须的,否则缓存可能把给 A 源的响应连着Allow-Origin: A一起发给 B 源。
🚦 边界这一半到此为止
⭐ 两句话收尾:同源策略是浏览器画的那条线,CORS 是你在线上开的那道门——⭐⭐ 门开成什么样完全由你的配置决定,所以这一节的篇幅几乎全花在「配错会怎样」上。
⚠️ 下一章 08c · CSRF 与 XSS 讲的是另一半:你一道门都没开的时候,攻击者怎么绕过这条线。 ⭐ 两条路都是那句地基的推论:CSRF 用「发得出」那一半(他根本不需要读响应),XSS 干脆钻到线里面去(他的代码成了你这个源的代码)。
🔄 换个栈怎么对应
| 概念(不会过期) | Python / FastAPI(本板块主栈) | Node(Express / Hono) | Go |
|---|---|---|---|
| CORS 白名单 | CORSMiddleware(allow_origins=[...]) |
cors() 中间件 🗓️ |
rs/cors 🗓️ 或自己写中间件 |
| 预检怎么处理 | ⭐ 中间件自动应答 OPTIONS |
同左 | 同左(自己写就得手动应答) |
| 允许带 Cookie 的跨源请求 | allow_credentials=True |
中间件的 credentials 选项 🗓️ |
中间件的 AllowCredentials 🗓️ |
⚠️ * + 凭证时的行为 |
回显 Origin(本节实测) |
⚠️ 各家不同,🗓️ 自己打一发预检看回什么 | 同左 |
Vary: Origin |
回显具体源时中间件自动带 | 🗓️ 看中间件,不一定带 | 自己写就得手动加 |
| 页面和接口同源托管(根本不用 CORS) | FileResponse / StaticFiles |
express.static |
http.FileServer |
⭐ 这张表要看出的是:只有第一列和最后一列的「叫什么」在变。
Origin/Access-Control-Allow-Origin/Access-Control-Request-Method/Vary是 HTTP 和浏览器的规矩,不是任何框架的发明——换栈之后一个字都不会变,变的只是「配置它的那个函数叫什么名字」。⭐⭐ 也正因如此,这一章和下一章的知识比本板块任何一章都保值。 ⚠️ 唯一真会各家不同的是第四行:规范说「*不能和凭证同时成立」,但没说框架该怎么应对,于是有的报错、有的回显。别推断,去打一发预检看它回什么。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ⭐⭐ 08c · CSRF 与 XSS | 边界立好了,接下来是攻击者怎么绕过它:CSRF 走本章那句「发得出」,XSS 直接钻进边界内部。⭐ 那一章还有 AI 应用独有的一条 XSS 路径 |
| ⭐⭐ 08 · 认证、会话与多租户 | 本章是那一章第三节那张表「跨域要配 CORS」那一格的展开。⚠️ 反过来,访问控制在那一章不在这里——本章第二节实测过:CORS 拒绝的请求,业务函数照样执行 |
| 09 · 最小可用前端 | 那一章第六节用 StaticFiles 把页面和接口放在同一个源,所以你现在还不需要 CORS;⚠️ 它那张「上框架买进什么」表里「静态文件单独托管」那一行,就是你从同源走到跨源的那一步 |
| ⭐ 16 · 上线前检查单 | 安全那节「CORS 不是 *」那条的验证动作在那里(带一个陌生 Origin 打预检)。⭐ 判据是「既不是 * 也不是那个陌生域名」——本章第二节讲了为什么只看「不是 *」会被骗过去 |
| 10 · 把流式接到界面上 | 那一章第二节的 fetch 同时带 Authorization 和 JSON body,就是本章说的「两条都踩、必触发预检」;跨域部署时每次对话前都会多一个 OPTIONS |
| 14 · 容器化与部署 | 你从同源走到跨源,往往是「静态站点单独部署」带来的,域名、证书和反向代理都在那一章;⚠️ CORS 头也能加在代理层,但别和应用里的白名单变成两份 |
✅ 检查点
- 「源」由哪三段构成?
https://app.example.com和https://api.example.com同源吗? - ⭐ 同源策略限制的到底是什么、不限制什么?这一条为什么是这两章其余部分的地基?
- ⭐⭐ 本章那段实测里,被 CORS 拒绝的那一发请求,服务端发生了什么?由此该怎么描述 CORS 的作用范围?
- 什么样的跨源请求会触发预检?第 10 章那个
fetch踩了哪几条?由此产生的那个「误诊症状」是什么样、该去日志里搜什么? - ⭐⭐
allow_origins=["*"]配allow_credentials=True会发生什么?为什么框架要那么做?这个配置为什么特别难被发现? - ⭐ 按本板块第 1 章、第 9 章的默认架构,你现在需要配 CORS 吗?哪一步会让你开始需要它?
- Starlette 那三条
400文案分别对应漏配了哪一格?加一个X-Tenant-Id头会撞上哪一条? Vary: Origin是干什么的?什么时候少了它会出事?
👀 答案
- 协议 + 域名 + 端口,三段字符串全等才同源。⚠️ 不同源——子域不同就算不同源,「同一家公司的域名」不是判据。
- ⭐⭐ 它限制的是「读响应」,不限制「发请求」。 跨源请求一直都发得出去,浏览器只是不把响应交给发起它的 JS——因为
<img><script><form>从 Web 第一天起就跨源,同源策略是后来加的,只能加「读不到」。CORS(放宽读)、CSRF(利用发得出)、XSS(代码进了边界内)三个洞全长在这句话上,⭐ 后两个在 08c。 - ⭐⭐ 业务函数照样执行了 1 次,返回了 200 和完整响应体,只是响应里没有
Access-Control-Allow-Origin(实测 C 组:预检400 Disallowed CORS origin,实发 200)。所以 CORS 决定的是「浏览器让不让 JS 读这个响应」,不是访问控制——⚠️ 它挡不住curl和任何非浏览器客户端;访问控制在第 8 章。 - 三条任一不满足就预检:方法不是 GET/HEAD/POST、带了安全名单外的请求头、
Content-Type不是那三种。⭐ 第 10 章的fetch踩了两条:Authorization是自定义头,Content-Type: application/json不在三种里。⚠️ 症状:curl通、浏览器报 CORS 错、服务端日志里连POST都没有——因为死在预检,⭐ 要去搜OPTIONS。 - ⭐⭐ 浏览器不接受「
*+ 带凭证」,于是 Starlette 把请求里的Origin原样回显(实测回了https://evil.example+Allow-Credentials: true,连现编的x-anything-i-want头也照抄进Allow-Headers)。结果是「任意站点带着用户 Cookie 访问并读走响应」。难发现的三个原因:响应里看不到*(是个正常域名)、自测永远绿(回显的就是你自己的源)、没有任何报错或指标波动。⭐ 验证时判据必须是「既不是*、也不是那个陌生 Origin」。 - ⭐ 不需要:第 1 章的
FileResponse("index.html")和第 9 章第六节的StaticFiles都是从后端把页面发出去,页面和接口同源。开始需要它的那一步是「静态文件单独构建、单独托管」(第 9 章那张「上框架买进什么」表的第二行)。⚠️ 还同源的时候别先配上——配了就是白开一道门。 Disallowed CORS origin= 源没在白名单;Disallowed CORS method= 方法没列进allow_methods;Disallowed CORS headers= 请求头没列进allow_headers。⚠️ 加一个X-Tenant-Id撞第三条:它是自定义头,既触发预检,又必须出现在Access-Control-Allow-Headers里。- 它告诉缓存「这个响应随
Origin而不同」。⚠️ 回显具体源时必须有(实测 A / C 两组带了,真*的 B 没带),否则中间的 CDN 或缓存代理可能把给 A 源的响应连同Allow-Origin: A一起发给 B 源。
🛑 可以停在这里
⚡ 走神救援
⭐⭐ 地基只有一句:同源策略限制的是「读响应」,不是「发请求」。 源 = 协议 + 域名 + 端口三段全等,⚠️ 子域不同也是不同源。跨源请求一直都发得出去(
<img><script><form>从 Web 第一天就跨源,同源策略是后来加的),三个洞就长在它的三个方向上:CORS 是你主动开的门(本章)、CSRF 借用凭证、XSS 钻进边界内部(后两个在 08d)。🔍 本板块默认架构是同源的(第 1 章FileResponse、第 9 章StaticFiles),⚠️ 还同源时别先把 CORS 配上——需要它的那一步是「静态文件单独托管」。⭐ CORS 是放宽不是收紧,不配才最严。⭐⭐ 实测三组配置,被 CORS 拒绝的那一发业务函数照样执行了 1 次、返回 200 和完整响应体,只少了Access-Control-Allow-Origin头——所以它决定的是「浏览器让不让 JS 读」,不是访问控制,挡不住curl。预检:方法不在 GET/HEAD/POST、带了安全名单外的头、Content-Type不是那三种,任一条不满足就先发OPTIONS;⭐ 第 10 章那个fetch两条都踩,⚠️ 症状是「curl 通、浏览器报 CORS、日志里连 POST 都没有」,要搜OPTIONS。💀 最贵的配置错误:allow_origins=["*"]配allow_credentials=True——浏览器不接受「*+ 带凭证」,框架就把Origin原样回显(实测回https://evil.example+Allow-Credentials: true),等于任意站点带着用户 Cookie 读走响应;⚠️ 难发现在于响应里看不到*、自测永远绿、指标不会动。⭐ 验证判据是「既不是*也不是那个陌生 Origin」。正确配法是具体的源清单,别用前缀匹配(https://app.example.com.evil.test也是https://开头)。🗓️ 回显具体源要带Vary: Origin,否则 CDN 会把 A 源的响应发给 B 源。
下一节 👉 08c-CSRF与XSS.md