🏠 总目录📚 本教程 08b · 同源策略与 CORS
📑 本页目录(点开跳转)

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 章第六节用 StaticFilesstatic/ 目录——页面和接口同源。 ⚠️ 需要 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 访问你的接口并读走响应」——正是同源策略存在的全部理由。而且:

  1. 响应里看不到 *,你看到的是一个具体的、看起来很正常的域名
  2. 自测永远是绿的——你用自己的页面测,回显的就是你自己的源
  3. 没有任何报错、告警、指标会动

⭐ 这就是第 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 头也能加在代理层,但别和应用里的白名单变成两份

✅ 检查点

  1. 「源」由哪三段构成?https://app.example.comhttps://api.example.com 同源吗?
  2. ⭐ 同源策略限制的到底是什么、不限制什么?这一条为什么是这两章其余部分的地基?
  3. ⭐⭐ 本章那段实测里,被 CORS 拒绝的那一发请求,服务端发生了什么?由此该怎么描述 CORS 的作用范围?
  4. 什么样的跨源请求会触发预检?第 10 章那个 fetch 踩了哪几条?由此产生的那个「误诊症状」是什么样、该去日志里搜什么?
  5. ⭐⭐ allow_origins=["*"]allow_credentials=True 会发生什么?为什么框架要那么做?这个配置为什么特别难被发现?
  6. ⭐ 按本板块第 1 章、第 9 章的默认架构,你现在需要配 CORS 吗?哪一步会让你开始需要它
  7. Starlette 那三条 400 文案分别对应漏配了哪一格?加一个 X-Tenant-Id 头会撞上哪一条?
  8. Vary: Origin 是干什么的?什么时候少了它会出事?
👀 答案
  1. 协议 + 域名 + 端口,三段字符串全等才同源。⚠️ 不同源——子域不同就算不同源,「同一家公司的域名」不是判据。
  2. ⭐⭐ 它限制的是「读响应」,不限制「发请求」。 跨源请求一直都发得出去,浏览器只是不把响应交给发起它的 JS——因为 <img> <script> <form> 从 Web 第一天起就跨源,同源策略是后来加的,只能加「读不到」。CORS(放宽读)、CSRF(利用发得出)、XSS(代码进了边界内)三个洞全长在这句话上,⭐ 后两个在 08c
  3. ⭐⭐ 业务函数照样执行了 1 次,返回了 200 和完整响应体,只是响应里没有 Access-Control-Allow-Origin(实测 C 组:预检 400 Disallowed CORS origin,实发 200)。所以 CORS 决定的是「浏览器让不让 JS 读这个响应」,不是访问控制——⚠️ 它挡不住 curl 和任何非浏览器客户端;访问控制在第 8 章。
  4. 三条任一不满足就预检:方法不是 GET/HEAD/POST、带了安全名单外的请求头、Content-Type 不是那三种。⭐ 第 10 章的 fetch 踩了两条Authorization 是自定义头,Content-Type: application/json 不在三种里。⚠️ 症状:curl 通、浏览器报 CORS 错、服务端日志里连 POST 都没有——因为死在预检,⭐ 要去搜 OPTIONS
  5. ⭐⭐ 浏览器不接受「* + 带凭证」,于是 Starlette 把请求里的 Origin 原样回显(实测回了 https://evil.example + Allow-Credentials: true,连现编的 x-anything-i-want 头也照抄进 Allow-Headers)。结果是「任意站点带着用户 Cookie 访问并读走响应」。难发现的三个原因:响应里看不到 *(是个正常域名)、自测永远绿(回显的就是你自己的源)、没有任何报错或指标波动。⭐ 验证时判据必须是「既不是 *、也不是那个陌生 Origin」。
  6. 不需要:第 1 章的 FileResponse("index.html") 和第 9 章第六节的 StaticFiles 都是从后端把页面发出去,页面和接口同源。开始需要它的那一步是「静态文件单独构建、单独托管」(第 9 章那张「上框架买进什么」表的第二行)。⚠️ 还同源的时候别先配上——配了就是白开一道门。
  7. Disallowed CORS origin = 源没在白名单Disallowed CORS method = 方法没列进 allow_methodsDisallowed CORS headers = 请求头没列进 allow_headers。⚠️ 加一个 X-Tenant-Id第三条:它是自定义头,既触发预检,又必须出现在 Access-Control-Allow-Headers 里。
  8. 它告诉缓存「这个响应随 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

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