想象你住在“青柠社区”,这是个封闭式小区——保安只放行本社区的住户,外来人员一律拦在门外。这就是浏览器的同源策略:只有协议、域名、端口完全一致的页面,才能互相读取资源。可如今互联网应用大多是分布式部署的:前端在 A 社区,后端 API 在 B 社区。你总不能每次都翻墙过去吧? 于是,浏览器社区和服务器社区之间约定了一套“跨社区拜访制度”——CORS(跨域资源共享)。拜访有两种方式:一种是“临时拜访卡”,简单登记就放行(简单请求);另一种是“先到物业办公室开拜访单”,保安核实后才放行(预检请求)。物业办公室会在拜访单上写清楚“允许谁进入”(Access-Control-Allow-Origin)、“是否允许携带贵重物品”(Access-Control-Allow-Credentials)、“拜访单有效期多久”(Access-Control-Max-Age)。 这篇文章就像一本“跨社区拜访手册”,带你读懂浏览器、前端、后端三方之间的每一张单据、每一个盖章,从此跨域配置不再靠复制粘贴碰运气。

岗位关联

对 AI 从业者而言,理解 CORS 意味着能独立排查前后端联调中 80% 的跨域报错,这是从“调通接口”走向“懂网络协议”的必经一步,也是大模型应用开发中部署 Web 服务的基本功。

目录
  1. 可视化演示
  2. 同源策略
  3. 简单请求
  4. 预检请求
  5. 响应头授权
  6. 请求头受限
  7. 凭证携带
  8. 缓存与兜底
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

\u5c55\u793a\u5341\u8fdb\u5236\u3001\u4e8c\u8fdb\u5236\u548c\u5341\u516d\u8fdb\u5236\u4e4b\u95f4\u7684\u8f6c\u6362\u8fc7\u7a0b\uff0c\u901a\u8fc7\u4f4d\u6743\u5206\u89e3\u548c\u5341\u516d\u8fdb\u5236\u7f29\u5199\u7406\u89e3\u6570\u5b57\u8868\u793a\u3002

本节配有交互动效(进入视口后自动加载,约 3KB)。

\u8fdb\u5236\u8f6c\u6362 \u4e8c\u8fdb\u5236\u4f4d \u5f53\u524d\u4f4d \u2211 bit\u00d72^n \u4f4d\u6743\u5206\u89e3\u8f6c\u6362\u8fc7\u7a0b

同源策略

青柠社区门口的保安是出了名的严格。他的登记本上写着三条规矩:协议要一样(访客和住户都得走同样的门禁协议),域名要一样(都得是青柠社区的门牌),端口要一样(都得走同一个侧门)。只有三条都满足,保安才认为你们是“一家人”,资源随便看。这就是浏览器的同源策略。

在 Web 世界里,“同源”指协议、域名、端口三者完全一致。比如 https://api.example.com 和 https://www.example.com 虽然域名都带有 example.com,但二级域名不同,就是跨域。同源策略是浏览器最基础的安全防线——它保证一个页面只能读取同源资源,防止恶意网站偷看你在银行页面的数据。

同源策略的拦截对象主要是“读取”操作。页面可以发送请求(比如提交表单),但浏览器会拦截脚本对响应数据的读取。特别是 XMLHttpRequest 和 fetch 这类 API,跨域请求发出去了,服务器也响应了,但浏览器把响应数据扣在保安亭,JavaScript 拿不到。

保安的规矩虽然严谨,却也挡了不少正经客人。繁花社区的前端要调用青柠社区的后端接口,数据就是拿不到。好在社区之间早已有了正式的拜访制度——CORS。它不是让保安放水,而是让跨社区拜访变得有章可循、签过协议再放行。

同源判定=(协议A=协议B)∧(域名A=域名B)∧(端口A=端口B)\text{同源判定}=( \text{协议}_A= \text{协议}_B) \land ( \text{域名}_A= \text{域名}_B) \land ( \text{端口}_A= \text{端口}_B)
三个条件只要有一个不满足,就是跨域。注意:判定的是“发起请求的页面”和“请求的目标 URL”是否同源。
  1. 协议比较比较两个 URL 的协议部分(http/https)是否完全相同,这是第一道关卡。
    协议A=协议B\text{协议}_A = \text{协议}_B
  2. 域名比较比较两个 URL 的主机名是否完全一致,example.com 和 www.example.com 被视为不同。
    域名A=域名B\text{域名}_A = \text{域名}_B
  3. 端口比较比较端口号是否相同,没有显式写端口时默认 http 为 80、https 为 443。
    端口A=端口B\text{端口}_A = \text{端口}_B
  4. 逻辑与三个条件必须同时成立才算同源,任何一个不满足都触发 CORS 检查流程。
    A∧B∧CA \land B \land C

简单请求

社区之间商量出了两种拜访方式。第一种叫“临时拜访卡”:访客在门口登记一下姓名和事由,保安直接放行,不需要去物业办公室开复杂的单子。对应到 HTTP 世界里,这类请求叫简单请求。只要满足三个条件,浏览器就不会触发预检流程,直接发出请求。

第一个条件是方法限定:只能是 GET、POST、HEAD 三种。这三种方法只读或只提交数据,不涉及修改资源的危险操作。第二个条件是 Content-Type 限定:只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded 这三种。这三种编码格式都是表单页面的原生类型,不会携带复杂结构。第三个条件是请求头限定:只能使用 CORS 安全列表内的请求头(如 Accept、Content-Type 等),不能夹带自定义请求头。

如果三个条件全部满足,浏览器就把请求当作“临时拜访卡”,直接发出并允许读取响应。

临时拜访卡的好处是快,但限制也明显:如果你想用 PUT 方法或者带一个 X-Auth-Token 的自定义头,保安就没有权力放行了。这时候你有两个选择:放弃这些需求,或者走第二条路——预检请求。怎么选,取决于你的业务到底需要多复杂的操作。

简单请求=(METHOD∈{GET,POST,HEAD})∧(Content-Type∈Csafe)∧(自定义头=∅)\text{简单请求}= (\text{METHOD} \in \{GET, POST, HEAD\}) \land (\text{Content-Type} \in \mathcal{C}_{safe}) \land (\text{自定义头} = \varnothing)
简单请求是 CORS 最省事的路径:不经过预检,请求直接发出,响应头里带上 Access-Control-Allow-Origin 就能被页面读取。
  1. 方法集合请求方法必须是 GET、POST、HEAD 三者之一,这是第一道关卡。
    METHOD∈{GET,POST,HEAD}\text{METHOD} \in \{GET, POST, HEAD\}
  2. 类型集合请求的 Content-Type 必须是三种表单编码格式之一,用 \(\mathcal{C}_{safe}\) 表示安全类型集合。
    Content-Type∈Csafe\text{Content-Type} \in \mathcal{C}_{safe}
  3. 无自定义头请求不能携带自定义请求头,所有头部必须来自浏览器自动添加的安全头列表。
    自定义头=∅\text{自定义头} = \varnothing
  4. 三个 AND三个条件全部满足时,请求走免预检的绿色通道。
    A∧B∧CA \land B \land C

预检请求

假设你带着一个需要特殊审批的诉求来拜访——比如用 PUT 方法修改资源,或者带一个自定义的 X-User-Token 头。保安不敢做主,他让你先去物业办公室开一张“拜访单”。这个去物业办公室咨询的过程,在 HTTP 里就是预检请求。

预检请求用的是 OPTIONS 方法,它在正式请求之前发出,专门询问三件事:允许哪些方法?(Access-Control-Request-Method)、允许哪些请求头?(Access-Control-Request-Headers)、我来自哪个源?(Origin)。服务器看到这张咨询单后,会核实自己的授权策略,然后返回预检响应。

如果预检响应表明“允许 PUT 方法、允许 X-User-Token 头”,浏览器才敢发出真正的请求。如果服务器说“不允许”,浏览器就直接拦下,连正式请求都不会发出——你连犯错的机会都没有,却能省下一次无效的网络往返。

物业办公室的效率比你想象的高——它不只答复当下的问题,还会顺手给你一张“长期拜访单”。只要服务器在预检响应里写了 Access-Control-Max-Age,这张单子在一定时间内都有效。拜访单开好了,但能不能进门,还得看保安手里的那份白名单——也就是服务器配置的那一串响应头。

预检请求:OPTIONS→{Access-Control-Request-Method: PUTAccess-Control-Request-Headers: X-User-TokenOrigin: https://front.example.com\text{预检请求}: \text{OPTIONS} \rightarrow \begin{cases}\text{Access-Control-Request-Method: PUT} \\\text{Access-Control-Request-Headers: X-User-Token} \\\text{Origin: https://front.example.com}\end{cases}
预检请求的本质是“先问后做”:浏览器用 OPTIONS 问一次,服务器用一段授权声明回答,双方确认无误后才开始真正的业务请求。
  1. OPTIONS 方法预检请求固定使用 OPTIONS 方法,它不代表任何业务操作,只用于“询问”。
    OPTIONS→服务器\text{OPTIONS} \rightarrow \text{服务器}
  2. 请求方法声明浏览器告诉服务器“我接下来想用 PUT 方法”,对应 Access-Control-Request-Method 头。
    Request-Method: PUT\text{Request-Method: PUT}
  3. 请求头声明浏览器告诉服务器“我还想携带 X-User-Token 头”,对应 Access-Control-Request-Headers 头。
    Request-Headers: X-User-Token\text{Request-Headers: X-User-Token}
  4. 来源声明浏览器在 Origin 头中声明自己是哪个源发起的请求,服务器据此判断是否信任。
    Origin: front.example.com\text{Origin: front.example.com}

响应头授权

物业办公室的工作台上放着一本“准入白名单”,上面用三种不同颜色的笔写满了规则。这张白名单才是整个跨社区拜访制度的核心——它的正式名称叫响应头授权。

第一种笔写的是来源名单(Access-Control-Allow-Origin):指定哪些源可以读取响应。它可以写死一个具体的源(如 https://front.example.com),也可以写个通配符 * 表示“谁都能来”。第二种笔写的是方法名单(Access-Control-Allow-Methods):列出允许的 HTTP 方法,如 GET, POST, PUT, DELETE。第三种笔写的是请求头名单(Access-Control-Allow-Headers):列出允许携带的自定义请求头,如 Content-Type, X-User-Token。

这三个响应头配合在一起,就是物业办公室给浏览器看的“准入白名单”。浏览器拿到响应后,会拿白名单去核对眼前这个请求:源在不在名单里?方法在不在名单里?头在不在名单里?全部命中才放行,有一条不满足就拦截。

白名单的配置是一个繁琐的体力活:每加一个头、每加一种方法,都要改动服务端配置。更麻烦的是,如果忘了配 Allow-Headers,浏览器会以"Request header field xxx is not allowed by Access-Control-Allow-Headers"为由拦下请求。这个报错是前端联调中最常见的 CORS 之一,原因就在这本白名单少写了一行。

放行条件=(Origin∈Allow-Origin)∧(METHOD∈Allow-Methods)∧(Headers⊆Allow-Headers)\text{放行条件} = ( \text{Origin} \in \text{Allow-Origin}) \land (\text{METHOD} \in \text{Allow-Methods}) \land (\text{Headers} \subseteq \text{Allow-Headers})
响应头是服务端对浏览器的“承诺”,浏览器信任这份承诺来放行跨域资源。配置时务必把需要开放的源、方法、头写得精确。
  1. 来源匹配请求的 Origin 必须在 Access-Control-Allow-Origin 声明的集合中,精确字符串匹配。
    Origin∈Allow-Origin\text{Origin} \in \text{Allow-Origin}
  2. 方法匹配请求的 Method 必须在 Access-Control-Allow-Methods 声明的集合中。
    METHOD∈Allow-Methods\text{METHOD} \in \text{Allow-Methods}
  3. 头的子集关系请求携带的每个自定义头,都必须在 Access-Control-Allow-Headers 的集合中,用子集符号表示。
    Headers⊆Allow-Headers\text{Headers} \subseteq \text{Allow-Headers}
  4. 三重与三个条件必须同时满足,响应数据才允许被脚本读取。
    A∧B∧CA \land B \land C

请求头受限

为什么浏览器对请求头这么敏感?回到社区的比喻:保安能快速放行的前提,是访客“只带常规物品”。如果你拎着一个看不清内容的黑色公文包,保安必须核实里面装的是什么。HTTP 中的自定义请求头就是这个黑色公文包——它可能携带认证令牌、追踪信息等敏感数据,浏览器不敢擅自放行。

因此 CORS 定义了一个安全请求头列表,只允许这些头出现在简单请求中。比如 Accept、Accept-Language、Content-Language、Content-Type(限三种值),以及 DPR、Downlink 等少数几个浏览器自动添加的头。除此之外的请求头,如 Authorization、X-Custom-Header,一律需要通过预检申报。

这种机制的本质是最小授权:简单请求只放行浏览器可以自己生成、内容可预测的安全头;凡是可能携带敏感信息的头,都要经过服务器显式授权才能使用,用来防止跨站请求伪造(CSRF)等攻击。

理解了这个机制,你就能解释一个前端经典困惑:“为什么我加了一个 Authorization 头,请求就多了一次 OPTIONS?”因为 Authorization 不在安全头列表里,浏览器需要先征求服务器同意才能让你带这个头。这就像保安看到你包里露出一角的工作证(Authorization),必须确认你确实是来办事的才放行。

Hsafe={accept,accept−language,content−language,content−type∣3}∪Hbrowser\mathcal{H}_{safe} = \{accept, accept-language, content-language, content-type|_{3}\} \cup \mathcal{H}_{browser}
简单请求放行的请求头集合是有限的“安全白名单”,超出这个集合的头部都会触发预检。前端常用的 Authorization 头就属于这一情况。
  1. 安全头集合定义 CORS 安全请求头集合,包含 Accept、Accept-Language等浏览器标准头。
    Hsafe={accept,… }\mathcal{H}_{safe} = \{accept, \dots\}
  2. Content-Type 限制Content-Type 只有三种取值时可以免预检,用 \(\text{|}_{3}\) 表示“限三种类型”。
    content−type∣{text/plain,multipart/form−data,urlencoded}content-type|_{\{text/plain, multipart/form-data, urlencoded\}}
  3. 浏览器自动头浏览器自动添加的头部也属于安全范围,用户无法控制它们的内容,所以可信。
    ∪ Hbrowser\cup \ \mathcal{H}_{browser}
  4. 其余皆需预检落在集合之外的请求头(如 Authorization、X-Token)都必须先发送预检请求申报。
    x∈headers∧x∉Hsafe⇒preflightx \in \text{headers} \land x \notin \mathcal{H}_{safe} \Rightarrow \text{preflight}

凭证携带

在一些场景里,跨域请求需要携带凭证——最典型的就是浏览器自动附加的 Cookie。回到社区的比喻:你要去隔壁社区拜访,口袋里装着自家的门禁卡(Cookie),这张卡在对方社区不一定被承认。CORS 对凭证携带有一套专门的规则,比普通跨域更严格。

第一个规则是前端需要显式开启:XMLHttpRequest 要设置 withCredentials = true,fetch 要设置 credentials: 'include'。默认情况下浏览器不会在跨域请求中附带 Cookie,这是出于安全考虑——防止第三方网站借用你的登录态。第二个规则是服务端必须响应 Access-Control-Allow-Credentials: true:服务器明确说“我接受你的门禁卡”。

这里有个最容易踩的坑:当 Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能是 *,且 Allow-Headers 也不能是 *。因为通配符意味着“对所有人开放”,而携带凭证的请求必须知道具体是哪个源,否则凭证就可能被任意第三方读取。

理解了凭证携带的规则,你就掌握了调试“跨域登录”问题的钥匙。那种“明明在后端配了 CORS,但跨域请求就是不带 Cookie”的现象,往往是三缺一:要么前端没开 withCredentials,要么后端忘了配 Allow-Credentials,要么错误地用了 * 想图省事。

凭证放行=(withCredentials=true)∧(Allow-Credentials:true)∧(Allow-Origin≠∗)\text{凭证放行} = (\text{withCredentials} = true) \land (\text{Allow-Credentials}: true) \land (\text{Allow-Origin} \neq *)
Cookie、TLS 证书、HTTP 认证信息都属于凭证。凭证请求下通配符失效是 CORS 的核心安全约束,前端开发几乎必然遇到。
  1. 前端开启标志前端必须主动设置 withCredentials 为 true,否则即使服务器允许,Cookie 也不会携带。
    withCredentials=true\text{withCredentials} = true
  2. 服务器允许标志服务器必须响应 Access-Control-Allow-Credentials: true,表示接受凭证。
    Allow-Credentials:true\text{Allow-Credentials}: true
  3. 通配符禁用携带凭证时 Allow-Origin 不能使用 *,必须指定确切的源字符串。
    Allow-Origin≠∗\text{Allow-Origin} \neq *
  4. 三重条件只有三个条件全部满足,浏览器才会在跨域请求中附带 Cookie 并允许读取响应。
    A∧B∧CA \land B \land C

缓存与兜底

每次跨域请求都要走一遍预检流程,网络开销不小。好在你拿到了物业办公室发的“长期拜访单”——浏览器会在一定时间内缓存预检结果,这个时间由响应头 Access-Control-Max-Age 控制,单位是秒。比如 Access-Control-Max-Age: 600 表示 10 分钟内,同源同方法的预检请求不再重复发送。

缓存虽然是省事儿了,但生产环境的 CORS 配置依然要细心。一个常见的实践困境是:前端的源不固定(本地开发 localhost:8080、测试环境 test.example.com、生产环境 app.example.com),如果服务端只允许一个源,就需要动态读取请求的 Origin 头来回写。许多团队用 Nginx 统一处理 CORS,在反向代理层加上响应头,应用代码完全不感知跨域的存在。

还有一类容易踩坑的是兜底配置。比如你要允许所有源访问,但又要携带凭证,此时必须动态回显 Origin 的值,而不是用 *。再比如某些老版本浏览器不支持 Access-Control-Allow-Headers 多值,需要写成 Content-Type, Authorization 而不是 Content-Type, Authorization,。这些细节决定了线上真正跑起来时,跨域接口是丝滑还是翻车。

把缓存和兜底配置做好,CORS 就从“偶尔报错的麻烦事”变成了“静默运行的基础设施”。回顾整个拜访流程:同源策略是“非本社区不可入内”的规矩,简单请求是“登记即放行”的绿色通道,预检请求是“先问后做”的审批流程,响应头是物业的授权白名单,请求头限制是“禁止携带不明物品”的安全底线,凭证携带是“门禁卡互认”的特别条款——每一环都是浏览器和服务器之间的一种默契。

Tpreflight=Access-Control-Max-Age(单位:秒)T_{preflight} = \text{Access-Control-Max-Age} \quad ( \text{单位:秒})
Max-Age 的默认值因浏览器而异(Chrome 约 5 分钟),明确设置可以减少不必要的预检请求,显著降低跨域接口的延迟。
  1. 缓存时长定义服务器通过 Access-Control-Max-Age 头告诉浏览器预检结果可以缓存多久。
    T=Max-AgeT = \text{Max-Age}
  2. 缓存命中在缓存有效期内,同一源对同一资源的预检请求不会再发送,直接复用上次的结果。
    命中缓存⇒跳过 OPTIONS\text{命中缓存} \Rightarrow \text{跳过 OPTIONS}
  3. 缓存失效超过 Max-Age 后,浏览器重新发送 OPTIONS 预检请求,确保授权策略仍是最新的。
    t>T⇒重新预检t > T \Rightarrow \text{重新预检}

AI 应用场景

场景:AI 模型服务的跨域调用

阶段:模型部署与集成

解决的问题:训练好的机器学习模型通常部署在独立的后端服务上(如 model.example.com:8000),而前端可视化平台跑在 app.example.com。传统方式下,前端直接 fetch 模型 API 会被同源策略拦截,AI 应用无法在网页端实时调用模型推理。

具体案例:某公司做了个 AI 图像识别平台,前端要调用模型服务的 /predict 接口。由于模型服务与前端不同源,浏览器拦下了所有响应。通过 CORS 配置,后端加上 Access-Control-Allow-Origin: https://app.example.com、Access-Control-Allow-Methods: POST, OPTIONS,并在前端用 fetch('/predict', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) 请求,跨域推理畅通无阻。如果模型接口要用 Authorization 头携带 API Key,还需配置 Access-Control-Allow-Headers: Authorization 并处理后端的预检请求。

场景:大模型应用的流式响应与凭证

阶段:前端应用开发

解决的问题:基于 LLM 的聊天应用需要流式获取生成结果(text/event-stream),同时要携带用户登录凭证。如果前后端分离部署,CORS 凭证规则直接决定流式请求能否成功。

具体案例:一个 AI 客服系统前端在 chat.example.com,后端在 api.example.com,用户登录后用 Cookie 维持会话。配置 Access-Control-Allow-Origin: https://chat.example.com(不能用 \*)、Access-Control-Allow-Credentials: true,前端 fetch 设置 credentials: 'include',同时允许 Content-Type 头。这样流式聊天才能既跨域又带凭证,AI 回复逐字输出。

场景:AI 网关的统一跨域策略

阶段:平台架构设计

解决的问题:企业内部有多个 AI 模型服务、多个前端应用,如果每个服务各配各的 CORS,规则容易失控。通过统一的 API 网关(如 Nginx、Kong)统一配置 CORS,形成“单一授权入口”。

具体案例:某中台团队在 Nginx 层统一配置 add_header 'Access-Control-Allow-Origin' $http_origin always; 并配合 Access-Control-Allow-Credentials: true,对 OPTIONS 请求直接返回 204。后端模型服务无需关心跨域逻辑,前端统一从网关入口调用不同的 AI 能力,A/B 测试新模型时也不需要改任何跨域配置。

Python 演示

python51 行
# -*- coding: utf-8 -*-"""CORS 预检请求与响应头处理演示用 Python 标准库实现一个支持 CORS 的简单 HTTP 服务,模拟前端跨域调用时的预检(OPTIONS)与真实请求(GET/POST)流程。"""import jsonfrom http.server import BaseHTTPRequestHandler, HTTPServer ALLOW_ORIGIN = "http://front.example.com"  # 配置允许的源  class CORSHandler(BaseHTTPRequestHandler):    # 统一为所有响应附加 CORS 头    def _set_cors_headers(self):        self.send_header("Access-Control-Allow-Origin", ALLOW_ORIGIN)        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")        self.send_header("Access-Control-Allow-Headers", "Content-Type, X-User-Token")        self.send_header("Access-Control-Max-Age", "600")  # 预检结果缓存600秒     # 预检请求:浏览器先发 OPTIONS 咨询,服务端返回授权声明    def do_OPTIONS(self):        self.send_response(204)        self._set_cors_headers()        self.end_headers()     # GET 请求:返回一个 JSON 响应    def do_GET(self):        self.send_response(200)        self._set_cors_headers()        self.send_header("Content-Type", "application/json")        self.end_headers()        self.wfile.write(json.dumps({"message": "CORS ok"}).encode())     # POST 请求:接收 JSON 并回显    def do_POST(self):        length = int(self.headers.get("Content-Length", 0))        body = json.loads(self.rfile.read(length) or b"{}")        self.send_response(200)        self._set_cors_headers()        self.send_header("Content-Type", "application/json")        self.end_headers()        self.wfile.write(json.dumps({"received": body}).encode())     def log_message(self, fmt, *args):        print(f"[CORS本地服务] {fmt % args}")  if __name__ == "__main__":    server = HTTPServer(("127.0.0.1", 8080), CORSHandler)    print("启动 CORS 演示服务: http://127.0.0.1:8080")    server.serve_forever()
目的:本地启动一个带 CORS 授权的 HTTP 服务,前端跨域调用时可观察到完整的 OPTIONS 预检和正式请求流程。
  1. ALLOW_ORIGIN = "http://front.example.com"定义允许的源,相当于物业白名单里的来源名单。
  2. def _set_cors_headers(self)统一附加 CORS 响应头,避免每个处理方法重复写。
  3. Access-Control-Allow-Methods声明允许的请求方法,预检时浏览器核对它。
  4. Access-Control-Allow-Headers声明允许的请求头,前端带 X-User-Token 时才不会拦截。
  5. Access-Control-Max-Age, "600"预检结果缓存 10 分钟,减少重复 OPTIONS 请求。
  6. def do_OPTIONS(self)处理预检请求,返回 204 并附 CORS 头,不执行业务逻辑。
  7. def do_GET(self)处理简单请求,GET 配合无自定义头时可以免预检直接走这里。
  8. def do_POST(self)处理需要预检的 POST 请求,演示服务端回显 JSON 数据。

常见误区

误区初学者听到“CORS 会先发 OPTIONS 预检”,就以为所有跨域请求都会先多一次 OPTIONS。看到浏览器网络面板里有 OPTIONS,就断定服务端配置有问题。

正解实际上只有“非简单请求”才会触发预检。GET/POST 且只带安全头的请求是简单请求,直接发出、不经过 OPTIONS。判断自己的请求属于哪一类,只需对照三个条件:方法是 GET/POST/HEAD 吗?Content-Type 是三种表单类型之一吗?没有自定义请求头吗?三个都满足就是简单请求,否则才需要预检。

为何常见:网上教程大多以“跨域请求会先发 OPTIONS”切入,放大了预检的存在感。加上部分前端框架自动添加头(如 X-Requested-With),把本应简单的请求“升级”成了预检请求,更加深了这个印象。

误区很多人遇到跨域报错后,第一反应是“前端代码里补个 CORS 配置”,或者去浏览器设置里关掉跨域限制。实际上前端页面里写不出任何能“开启 CORS”的代码。

正解CORS 是浏览器的安全机制,授权规则完全由服务器端响应头决定。前端能做的只有:在 fetch 或 XMLHttpRequest 中设置是否携带凭证(credentials)。真正要解决问题,需要后端在响应中添加 Access-Control-Allow-Origin 等头部——要么业务层配置,要么网关/Nginx 层统一处理。

为何常见:错误信息总是出现在浏览器控制台,且错误堆栈会指向前端脚本的某一行,让人误以为是前端问题。此外,浏览器扩展(如“允许跨域”插件)恰好反证了“只有浏览器在管跨域”——它改的是浏览器的行为,而不是前端代码。

误区初学者配置服务端时,图省事直接写 `Access-Control-Allow-Origin: *`,心想“反正这样谁都能访问了,总不会再报错吧”。结果发现带上 Cookie 后请求反而失败了,或安全审计直接打回。

正解通配符 * 意味着“任何源都能读取响应”,一旦请求携带凭证(Cookie、TLS 证书等),浏览器会强制要求 Allow-Origin 必须是确切的源,不允许通配。同时,过多的 * 授权会放大敏感数据暴露风险。正确的做法是:能写具体源就写具体源,需要动态多源时读请求的 Origin 头回显。

为何常见:* 看起来“最省事、最不易报错”,且很多测试接口都这么写。但正式项目一旦接入登录状态或用户数据,通配符与凭证的冲突立即暴露,教训往往是踩过坑才记住。

误区后端接口明明返回了 200 和正常数据,但前端在浏览器里却拿不到,控制台报 CORS 错误。这时候 PM 和后端同事都指着网络面板说“响应都成功了,怎么还说跨域失败?”

正解CORS 的拦截发生在“响应返回之后”。服务器的请求处理已经完成、响应也到达了浏览器,但浏览器发现响应缺少必要的 Access-Control-Allow-* 头,于是充当保安的角色把数据扣下,不交给脚本。所以“服务器看到 200、浏览器看到 CORS 错误”完全不矛盾。排查时要看响应头里有没有 Access-Control-Allow-Origin。

为何常见:人的直觉是“请求成功 = 流程成功”,而浏览器恰恰在最后一步做拦截。服务端日志和客户端报错的不一致,让不熟悉 CORS 流程的人误以为问题出在传输层。

误区调试时看到网络面板里有个 `OPTIONS` 请求返回 404 或 405,第一反应是“后端接口不存在,把 OPTIONS 当成了普通接口”。于是拉着后端改路由,结果改了也没用。

正解预检请求是浏览器的“问路”,它并不携带你的业务数据,也不会执行业务逻辑。后端只需要在路由层统一响应 OPTIONS(返回 204 + CORS 头)即可,不需要为每个接口单独实现 OPTIONS 方法。很多框架(Spring、Express)都内置了 OPTIONS 处理,只要配置了 CORS 中间件就会自动响应。如果返回了 404,通常是框架层没有配置 CORS 中间件,而不是业务逻辑有问题。

为何常见:人们习惯用“接口 = 业务方法”的心智模型,难以理解 OPTIONS 这种“非业务请求”。看到 404/405 就认为是路由缺失,而实际上需要检查的是框架的 CORS 全局配置。

面试题

Q1. 以下哪个 URL 与 https://www.example.com:443/api 同源?

单选题 · easy

  • A.http://www.example.com:80/api
  • B.https://www.example.com:443/index← 正确答案
  • C.https://api.example.com:443/user
  • D.https://www.example.com:8443/home

答案:B。同源要求协议、域名、端口完全一致。B 与题干都是 https、www.example.com、443,只是路径不同,同源判定与路径无关。A 协议不同(http vs https),C 域名不同(api 子域 vs www),D 端口不同(8443 vs 443),均视为跨域。

Q2. 下列哪种情况属于“简单请求”,不会触发 CORS 预检?

单选题 · easy

  • A.GET 请求且 Content-Type 为 application/json
  • B.PUT 请求修改资源
  • C.POST 请求且 Content-Type 为 application/x-www-form-urlencoded,带 Accept 头← 正确答案
  • D.GET 请求携带自定义 X-Token 头

答案:C。简单请求需同时满足三个条件:方法为 GET/POST/HEAD、Content-Type 为三种表单编码之一、无自定义请求头。C 满足全部条件,是简单请求。A 的 Content-Type 是 application/json(不属于三种安全类型之一),B 的 PUT 超出方法集合,D 携带了自定义头,三者都会触发预检。

Q3. 预检请求使用什么 HTTP 方法?

单选题 · mid

  • A.GET
  • B.POST
  • C.OPTIONS← 正确答案
  • D.HEAD

答案:C。CORS 预检请求固定使用 OPTIONS 方法,浏览器通过它向服务器询问“这个跨域请求的方法和头是否被允许”。服务器返回 204 响应并附上 Access-Control-Allow-* 头。GET、POST 是业务请求的方法,HEAD 用于获取响应头但不返回响应体,都不是预检使用的方法。

Q4. 服务端要允许携带 Cookie 的跨域请求,以下哪个配置是正确的?

单选题 · mid

  • A.Access-Control-Allow-Origin: * 且 Access-Control-Allow-Credentials: true
  • B.Access-Control-Allow-Origin: https://front.example.com 且 Access-Control-Allow-Credentials: true← 正确答案
  • C.Access-Control-Allow-Origin: https://front.example.com 且 Access-Control-Allow-Credentials: false
  • D.Access-Control-Allow-Origin: * 且不加 Allow-Credentials 头

答案:B。凭证请求(携带 Cookie)要求 Allow-Origin 必须是确切的源,不能用通配符 *,同时服务端必须返回 Access-Control-Allow-Credentials: true。B 同时满足两个条件,是正确配置。A 因通配符与凭证冲突而非法,C 的 Allow-Credentials 为 false 不会放行凭证,D 完全没有允许凭证。

Q5. 关于 Access-Control-Max-Age 头的作用,下列说法正确的是?

单选题 · mid

  • A.设置 Cookie 的过期时间
  • B.设置预检响应结果在浏览器中的缓存时长← 正确答案
  • C.设置跨域请求的最大并发数
  • D.设置服务器开放跨域的时间窗口

答案:B。Access-Control-Max-Age 的值以秒为单位,告诉浏览器在指定时间内无需为同一跨域请求重复发送预检请求(OPTIONS)。它只作用于预检结果的缓存,与 Cookie 过期、并发数或服务器开放时段无关。合理设置该值可以减少网络往返,提升跨域接口性能。

Q6. 修改请求报错 "Request header field x-token is not allowed by Access-Control-Allow-Headers in preflight response",最可能的解决方案是?

单选题 · mid

  • A.在前端代码中去掉 x-token 头相关的 CORS 设置
  • B.在服务端响应中添加 Access-Control-Allow-Headers: x-token← 正确答案
  • C.在浏览器设置中关闭同源策略
  • D.将请求方法改为 GET

答案:B。该报错表示预检响应中声明的允许请求头列表不包含 x-token。解决方案是在服务端的 Access-Control-Allow-Headers 中补上该头,让预检通过。前端无法通过“去掉 CORS 设置”来解决问题,浏览器设置不属于开发范畴,改 GET 也不影响请求头是否被允许。

Q7. 前端脚本发起 fetch 请求时需要携带 Cookie,且服务端已配置 Access-Control-Allow-Credentials: true,前端还应怎么做?

单选题 · hard

  • A.在 fetch 中设置 credentials: 'include'← 正确答案
  • B.在 URL 中手动拼接 Cookie 参数
  • C.设置 Access-Control-Allow-Origin 请求头
  • D.无需任何设置,浏览器会自动携带 Cookie

答案:A。跨域请求默认不携带凭证,前端必须显式开启。fetch 中 credentials 设为 'include' 时,浏览器才会在跨域请求中附加 Cookie。B 把凭证放进 URL 既不安全也不被推荐,C 的 Allow-Origin 是响应头而非请求头,D 与“浏览器默认不携带”的规则相反。

Q8. 下列关于 CORS 简单请求的判定标准,正确的是?(多选)

多选题 · mid

  • A.方法必须是 GET、POST、HEAD 之一← 正确答案
  • B.Content-Type 只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded← 正确答案
  • C.请求头必须是 CORS 安全列表内的头部← 正确答案
  • D.请求必须携带 Accept 和 Accept-Language 头

答案:ABC。简单请求的三个判定标准是方法集合、Content-Type 安全类型集合、安全请求头列表(无自定义头),对应 A、B、C。D 错误——Accept 等安全头只是“允许存在”,并非“必须携带”。

Q9. 当一个跨域请求触发了 OPTIONS 预检,预检请求的请求头中通常包含哪些字段?(多选)

多选题 · hard

  • A.Access-Control-Request-Method← 正确答案
  • B.Access-Control-Request-Headers← 正确答案
  • C.Origin← 正确答案
  • D.Access-Control-Allow-Origin

答案:ABC。预检请求由浏览器自动发出,包含 Access-Control-Request-Method(声明准备使用的方法)、Access-Control-Request-Headers(声明准备携带的自定义头)、Origin(声明来源)。D 是预检响应中的授权头,不属于请求头。预检响应中才包含 Allow-Origin、Allow-Methods、Allow-Headers 和 Max-Age。

Q10. 生产环境中,如需支持多个前端源(如开发、测试、生产环境)访问同一 API,且需要携带凭证,以下哪些做法是合理的?(多选)

多选题 · extreme

  • A.在后端动态读取请求的 Origin 头,回显到 Access-Control-Allow-Origin← 正确答案
  • B.配置 Access-Control-Allow-Origin: * 并搭配 Allow-Credentials: true
  • C.在网关层统一维护允许的源列表,动态下发响应头← 正确答案
  • D.将允许的源列表写死在配置文件中,配置后不随环境变化

答案:AC。多源且带凭证时,不能使用通配符 \*,需动态读取 Origin 并校验后在 Allow-Origin 中精确回显(A),或在网关层统一做源列表管理和响应头下发(C)。B 的“通配符 + 凭证”组合非法。D 把源列表写死无法适配前端环境动态调整的需要,不是生产环境最佳实践。