ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CORS跨域报错排查指南:同源策略、预检请求与Nginx生产配置

CORS跨域报错排查指南:同源策略、预检请求与Nginx生产配置 几乎每个前后端联调的下午都会有人对着浏览器控制台发出一声叹息Access to XMLHttpRequest at http://localhost:8080/api/user from origin http://localhost:3000 has been blocked by CORS policy。这个has been blocked by CORS policy系列报错大概是 Web 开发里出现频率最高、同时也最容易被误会的报错之一。很多人第一反应是后端接口写错了也有人习惯性把前端请求地址改来改去折腾半天问题还在原地。这篇我会把 CORS 的来龙去脉、报错信息每一段的含义、前后端各自的解法、生产环境的部署思路以及那些伪装成 CORS 报错的坑一次性讲清楚。无论你是刚接触前后端分离的新手还是被线上环境跨域问题折磨过的全栈或运维都可以直接拿着这篇文章去对照排查。1. 先弄懂 CORS 到底在替你挡什么同源策略与浏览器授权机制1.1 同源策略所有跨域问题的源头要理解 CORSCross-Origin Resource Sharing跨域资源共享先得理解同源策略。浏览器在加载页面时会记录当前页面的“源”这个源由三部分组成协议protocol、域名host、端口port。比如http://localhost:3000这个地址协议是http域名是localhost端口是3000三者合一才构成一个完整的源。当页面里的 JavaScript 向另一个源发起请求比如从http://localhost:3000请求http://localhost:8080只要协议、域名、端口有一个不同就构成跨域。很多人只注意到域名不同才算跨域其实端口不同也算。前后端分离开发模式下前端跑在 3000 端口后端跑在 8080 端口哪怕都在 localhost 上浏览器照样按跨域处理。同理https://example.com和http://example.com也属于跨域因为协议不同。同源策略的初衷是安全如果没有这个限制你在浏览器里登录了网上银行再打开一个恶意网站恶意网站里的脚本就能悄悄向你银行的接口发请求、读响应账户数据就被窃取了。所以浏览器默认禁止页面脚本跨域读取响应数据。这里的关键词是“读取响应”——请求其实发出去了服务器也处理了只是浏览器把响应拦下来不交给页面脚本。这也是为什么很多人用 Postman 测接口一切正常放浏览器里却跨域报错Postman 这类工具不走浏览器同源策略自然测不出问题。1.2 CORS 是一次“服务器授权”的 HTTP 头部握手既然跨域请求被拦那怎么合法地放行CORS 就是答案。它本质上是一套基于 HTTP 头部的约定服务器在响应里带上Access-Control-Allow-Origin等头告诉浏览器“我允许你读取我的响应”。浏览器看到授权头匹配才把响应交给页面脚本。整个交互可以理解成一次门禁核验页面脚本发起跨域请求。浏览器先看请求是不是“简单请求”如果不是先发一个OPTIONS预检请求去问服务器“我打算用 POST application/json Authorization 头你允许吗”。服务器返回允许的源、方法、请求头。浏览器核对通过后才真正发出业务请求并在拿到响应后再次核对Access-Control-Allow-Origin通过才把数据交给脚本。所以 CORS 报错的根本原因不是前端代码写错了而是服务器没有在响应里正确声明授权。理解了这一点排查方向就清晰了要么让服务器正确返回 CORS 头要么让请求避免形成跨域。后面所有方案都围绕这两条路展开。2. 报错信息逐段拆解那几行英文到底说了什么2.1 “No ‘Access-Control-Allow-Origin’”的两种可能性拿最典型的报错举例Access to XMLHttpRequest at http://localhost:8080/api/user from origin http://localhost:3000 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.拆开看一共四段信息报错片段含义Access to XMLHttpRequest at ...发起请求的是 XHR/fetch目标接口地址是http://localhost:8080/api/userfrom origin http://localhost:3000当前页面所在的源也就是请求的“身份证”has been blocked by CORS policy被浏览器的 CORS 策略拦截不是服务器拒绝No Access-Control-Allow-Origin header is present响应里没有Access-Control-Allow-Origin头最后一句是关键它有两种可能服务器真的没返回这个头或者返回了但值和你当前的源不匹配。Chrome 对这两种情况有时会区分提示前者报No ... header is present后者报The Access-Control-Allow-Origin header has a value ... that is not equal to the supplied origin。看到第二种提示基本就是白名单里没写全你的源去对照一下大小写、端口、协议即可localhost和127.0.0.1在浏览器眼里也是两个不同的源这种细节最容易坑人。2.2 预检请求的触发边界为什么有时候请求会发两次浏览器把跨域请求分成两类。简单请求simple request直接发不需要预检非简单请求会先发一个OPTIONS预检preflight询问服务器允不允许然后才发真实业务请求。判断是不是简单请求需要同时满足三个条件方法只能是GET、HEAD、POST之一不能有自定义请求头或者只能有 CORS 安全列表里的头Accept、Accept-Language、Content-Language、Content-Type等Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain之一。实际开发中最常见触发预检的三种情况使用application/json作为 Content-Type、携带Authorization头、使用PUT或DELETE方法。随便中一个就会多出一轮 OPTIONS 请求。所以你在 DevTools Network 里看到同一个接口出现两条请求记录第一条是OPTIONS、第二条才是真正的业务请求这是正常现象不是 BUG。预检请求长这样OPTIONS /api/user Host: localhost:8080 Origin: http://localhost:3000 Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type服务器如果允许返回HTTP/1.1 204 No Content Access-Control-Allow-Origin: http://localhost:3000 Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 86400这里要特别强调预检这步没过浏览器直接报错真实业务请求根本不会发出去。我曾经见过一个团队在小程序接口联调时一直报跨域排查了半天业务接口最后发现是 OPTIONS 请求先被服务器返回了 500。先确认是哪一步失败再动手改能省下大量无用功。3. 后端配置才是正解主流框架的 CORS 规范写法3.1 FastAPI 的 CORSMiddleware 与 credentials 冲突FastAPI 底层是 Starlette官方提供了现成的CORSMiddleware。基本配置from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app FastAPI() app.add_middleware( CORSMiddleware, allow_origins[http://localhost:3000], allow_credentialsTrue, allow_methods[GET, POST, PUT, DELETE, OPTIONS], allow_headers[Content-Type, Authorization], expose_headers[X-Total-Count], max_age3600, )几个参数要特别注意。allow_origins一定要写具体的源别图省事写成[*]。尤其当allow_credentialsTrue时浏览器规范是禁止通配符和凭证同时使用的。FastAPI 在启动时如果检测到allow_origins[*]且allow_credentialsTrue会直接抛错allow_origins[*] and allow_credentialsTrue is not allowed。就算你用其他框架没有显式报错浏览器收到这种组合也会拒绝把响应交给脚本。allow_methods建议显式列出实际用到的方法或者用[*]展开成所有方法。allow_headers同理前端实际会带哪些头就声明哪些Authorization、Content-Type是最常见的。expose_headers经常被人忽略。如果你的后端返回了自定义响应头比如分页用的X-Total-Count、文件上传用的X-File-Url默认情况下前端脚本是读不到这些头的必须在expose_headers里声明后axios的response.headers里才能拿得到。这个坑我见过不止一次接口联调时发现响应头全都能看到代码里一读就是undefined最后查了一圈才发现是expose_headers没配。3.2 Express、Spring Boot、PHP 的配置对照Node.js 生态里 Express 最常用cors这个第三方包const cors require(cors); app.use(cors({ origin: http://localhost:3000, credentials: true, methods: [GET, POST, PUT, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization], }));注意cors包的origin参数也支持传函数可以根据请求的Origin动态返回值这就是“反射 Origin”的常见实现方式。后面我会专门展开反射 Origin 的安全问题。Spring Boot 里可以写一个全局配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }PHP 则比较直接在入口文件或公共接口文件里加响应头header(Access-Control-Allow-Origin: http://localhost:3000); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }PHP 有个细节很多人踩过只加了Access-Control-Allow-Origin头对预检的 OPTIONS 请求没做处理结果前端请求还是失败。OPTIONS 请求会正常到达 PHP你需要对它直接返回 204 并结束执行否则后面代码继续往下跑可能输出业务内容或者因为参数缺失返回 500。补充一个 Django 的配置因为热词里提到了 vuedjango 部署时的跨域问题。Django 通常用django-cors-headers这个库安装后在INSTALLED_APPS里加上corsheadersMIDDLEWARE里尽量往上加CorsMiddleware再配置CORS_ALLOWED_ORIGINS [ http://localhost:3000, ] CORS_ALLOW_CREDENTIALS True CORS_ALLOW_METHODS [GET, POST, PUT, DELETE, OPTIONS] CORS_ALLOW_HEADERS [Content-Type, Authorization]Django 的中间件顺序很关键CorsMiddleware最好放在其他可能返回响应的中间件之前尤其是有CommonMiddleware或自定义鉴权中间件时否则 OPTIONS 预检请求可能在到达 CORS 处理之前就被拦截了。4. 前端代理与 JSONP看起来解决跨域实际是绕开跨域4.1 dev server 代理的底层原理与 changeOrigin前端开发框架里Vite 和 Webpack 都提供了代理功能。原理很简单浏览器发出的请求是同源的都打向 dev server 的地址由 dev server 在服务端把请求转发给真正的后端。服务端和服务器之间的请求不受浏览器同源策略约束所以“跨域问题”在源头上就不存在了。Vite 里配置// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, };配置完后前端代码里请求/api/user浏览器实际访问的是http://localhost:3000/api/userVite dev server 收到后转发给http://localhost:8080/api/user。这里changeOrigin: true的作用是让后端看到的请求Host头变成localhost:8080如果不加后端拿到的Host还是localhost:3000。如果后端对request.headers.host有校验不加这个字段就会导致转发过去之后请求被后端拒绝。Webpack 的 devServer 配置逻辑类似// webpack.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, };需要提醒的是代理方案只解决“开发环境”的问题而且它本质上是让请求不发生跨域而不是让后端支持跨域。你的前端代码里如果写死了后端地址比如axios的baseURL直接填了http://localhost:8080那代理配置对你是不起作用的。想用代理前端代码里的请求地址必须是用相对路径/api/...由 dev server 去匹配代理规则。4.2 代理之后拿不到真实请求地址的问题热词里有一条“vue配置跨域代理后如何获取我的真实的请求地址”这个场景很典型。配置了代理之后后端日志里看到的请求来源是 dev server 的地址拿不到真实客户端 IP。要解决这个问题需要在代理转发时把原始 IP 信息带上——Nginx 或网关层通过X-Forwarded-For、X-Real-IP请求头传递后端再从这些头里取真实客户端地址。Vite 的 proxy 配置也支持自定义 headersproxy: { /api: { target: http://localhost:8080, changeOrigin: true, headers: { X-Real-IP: 127.0.0.1, }, }, }实际生产环境中更规范的做法是让 Nginx 统一注入这两个头proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;后端取时要注意一个安全细节X-Forwarded-For是用户可伪造的如果直接信任它取到的 IP 来做风控会被人轻易绕过。通常以最左边的非信任 IP 为准或者信任直接从 Nginx 连接的 IP这个根据你的链路层级来取舍。4.3 JSONP只能用于 GET 的老古董JSONP 的原理是躲开 XMLHttpRequest改用script标签加载跨域脚本。script的src不受同源策略限制服务器返回的是一段 JS 代码里面调用预先定义好的 callback 函数。前端动态插入script srchttp://localhost:8080/api/user?callbackhandleUser/script服务器返回handleUser({name: 张三});JSONP 的局限性非常大只能 GET、没有标准错误处理、依赖后端配合输出特定格式。现在除了对接某些历史遗留接口基本不推荐。如果你在老 PHP 项目里看到php跨域jsonp这个组合多半是那个接口年代久远且不好改。新项目请果断使用 CORS别为了兼容老接口给自己留技术债。5. 生产环境跨域决策同域部署优先Nginx 网关兜底5.1 为什么同域部署是最优解前端静态资源打包后的dist目录和后端 API 部署在同一个域名下通过路径区分https://example.com/提供页面https://example.com/api/提供接口。浏览器视角下这是同源请求完全不存在跨域问题代码和运维都省心。这个方案需要 Nginx 做路径转发静态文件直接 serve/api/前缀的请求proxy_pass给后端服务。配置示例server { listen 443 ssl; server_name example.com; # 前端静态资源 root /var/www/dist; index index.html; # 接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意 Vue 或 React 这类 SPA 应用通常还要处理前端路由非/api/的路径都可能命中前端路由需要在 Nginx 里配置try_files $uri $uri/ /index.html;否则刷新页面会出现 404。这虽然不是跨域问题却是在同域部署时最容易连着踩的坑。5.2 确实需要跨域时用 Nginx 统一收敛 CORS 头如果架构上确实需要api.example.com和www.example.com分离或者你要对外提供开放 API那就得在网关层统一处理 CORS。Nginx 配置示例server { listen 443 ssl; server_name api.example.com; location / { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; add_header Access-Control-Max-Age 86400; add_header Access-Control-Allow-Credentials true; return 204; } add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Credentials true; proxy_pass http://127.0.0.1:8080; } }几个关键点值得记下来。一是尽量用$http_origin动态反射请求的 Origin而不是写死单一域名这样可以同时服务多个前端域名。但配合Access-Control-Allow-Credentials true时反射 Origin 有安全隐患下一节专门说。二是 Nginx 的add_header有个继承规则你在某个location里写了add_header它会覆盖继承自上一层server的add_header。所以如果你在不同层级分散添加 CORS 头很容易出现“某些路径有头、某些路径没头”的诡异现象。建议把 CORS 配置放进单独的 include 文件# cors.inc add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization;然后在需要的地方统一 include。三是 OPTIONS 建议在网关层直接返回 204不转发给后端。这样后端业务代码完全不用感知 CORS 的存在职责更清晰。6. 伪装成 CORS 报错的坑一份排查链路实录6.1 反射 Origin credentialstrue 的组合隐患热词里有“cors 配置错误(反射 origin credentialstrue)”这是很多团队实际踩过的坑。为了图省事后端把Access-Control-Allow-Origin动态设置成请求的Origin前端传什么源就回什么源同时还要配合Access-Control-Allow-Credentials: true来支持携带 Cookie 的登录态。隐患在哪如果攻击者引诱用户访问恶意站点evil.com这个页面的脚本向你的 API 发请求时会自动带上用户浏览器里的 Cookie假设你的接口 Cookie 没有设置SameSite约束你反射回去了evil.com的 Origin 并且允许携带凭证等于在用户登录状态下放行了来自恶意站点的请求。攻击者虽然读不到跨域响应但如果是 POST 提交类的接口改密码、下单、删数据在凭证有效的情况下可能会被执行。正确做法是维护一个白名单只反射白名单里的源ALLOWED_ORIGINS {https://www.example.com, https://admin.example.com} def get_allow_origin(request): origin request.headers.get(Origin, ) if origin in ALLOWED_ORIGINS: return origin return None后端拿到Origin后先在白名单里判断命中才原样返回否则不返回 CORS 头。简单说要么用白名单精确匹配要么不搞凭证透传。两者都想要的必须守住白名单底线。另外还要记得加上Vary: Origin响应头否则浏览器或 CDN 缓存了某个 Origin 的响应会给其他源的请求复用导致奇怪的问题。6.2 OPTIONS 预检被鉴权中间件拦截症状请求头设置没问题Allow-Origin 也配了但还是报blocked by CORS policy。打开 DevTools Network能看到一个 OPTIONS 请求状态码是 401 或 403。原因通常是后端的鉴权中间件对 OPTIONS 请求也执行了 Token 校验。预检请求本身不带业务凭证浏览器不会给它带Authorization头所以直接被踢掉了。修法就是在网关或中间件里把 OPTIONS 请求放行直接返回 204。Spring Boot 的拦截器里要排除 OPTIONS 方法Nginx 的 location 里也提前拦截。如果是自研中间件判断request.method OPTIONS时直接 return不做任何鉴权逻辑。这里有一个隐含问题如果你把预检请求放行成了一个 200 而不是 204浏览器也能接受但 204 的语义最干净建议统一返回 204。而且预检响应是不需要返回业务数据的多余的响应体只会浪费带宽。6.3 Allow-Headers 漏配导致的预检失败前端加了X-Requested-With或自定义的X-App-Version头后端只配置了Allow-Headers: Content-Type, Authorization。预检请求发起时带着Access-Control-Request-Headers: x-app-version服务器返回的Allow-Headers里没有它浏览器直接判定预检失败。这种报错的信息通常是Request header field x-app-version is not allowed by Access-Control-Allow-Headers in preflight response。注意看报错里提到的字段名缺哪个补哪个。我的建议是如果你没有严格的头白名单需求Allow-Headers直接给*省得每次新增自定义头都要改后端配置。但Allow-Origin不要用*这个前面已经说过了。6.4 三步定位问题层级我把排查经验总结成三步基本覆盖 90% 的 CORS 疑难杂症。第一步先用 curl 模拟请求看后端到底返回了什么头curl -i -X OPTIONS http://localhost:8080/api/user \ -H Origin: http://localhost:3000 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type看响应里有没有Access-Control-Allow-Origin。如果 curl 都看不到说明后端根本没配问题在后端如果 curl 能看到说明后端没问题问题可能出在浏览器缓存、代理层或请求被中间链路的某环改写。第二步打开 DevTools Network找到那条被拦截的请求分别看 GeneralRequest Method 是 OPTIONS 还是 POST、Request HeadersOrigin 是不是当前页面源、Response HeadersAllow-Origin 到底有没有、值是什么。这一步能定位是预检失败还是真实请求失败。第三步按响应情况细分现象大概率根因OPTIONS 状态码非 2xx鉴权中间件或服务器拦截了预检OPTIONS 返回 2xx 但 Allow-Origin 缺失或值不匹配CORS 配置错误白名单没覆盖OPTIONS 正常且头正确真实请求失败真实请求的响应头被中间环节覆盖或后端业务异常浏览器报错但 curl 正常浏览器缓存了旧的预检响应或代理/CDN 缓存了 CORS 头这个排查链路我建议收藏起来下次遇到至少能少走一半弯路。最后分享一个我自己的习惯。接手任何一个项目我都会先问一句前端打包之后能不能跟后端放同一个域能就优先同域部署这能省掉后面所有跨域相关的运维成本。实在不行再用网关统一加 CORS 头把跨域逻辑收敛在网关一层别让每个业务接口自己写 CORS 代码。分散在各处的跨域配置迟早会给你埋一颗“线上环境突然跨域”的雷。踩过几次坑之后你就明白CORS 报错不可怕可怕的是连它到底在哪一层出的问题都没搞清楚就急着改代码——先定位再动手永远比盲目尝试高效。
返回列表