免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

跨域全解读:CORS原理、配置与常见坑

跨域全解读:CORS原理、配置与常见坑 做前端的人几乎没有不跟跨域打交道的尤其是“浏览器访问跨域”这四个字几乎每天都会出现在调试台上。不管是本地开发时接口调不通还是上线后某个页面白屏十有八九都能看到 CORS 报错。我见过不少团队前端把锅扔给后端后端把锅推回给前端两边对着浏览器控制台吵半天最后才发现只是几个响应头没配对。今天这篇就把浏览器跨域这件事从头到尾拆开CORS 是什么、为什么要有、怎么在开发和部署时正确处理一次讲透。1. 跨域到底是怎么回事浏览器的同源策略1.1 同源的判定规则判断一个请求是不是跨域浏览器只看三样东西协议、域名、端口。只要这三者有一个不一样就是跨域。打个比方我在浏览器地址栏输入https://www.example.com/login页面里用 fetch 去请求https://api.example.com/user这两个地址域名不一样属于跨域。如果页面是http://localhost:8080接口是http://localhost:3000端口不一样同样跨域。还有更隐蔽的http://localhost和http://127.0.0.1表面上都是本机但浏览器把localhost和127.0.0.1视为不同源因为域名不同。很多新手在本地联调时被这个坑过后端把服务跑在 127.0.0.1前端页面开在 localhost一直报跨域实际上两边是同一台机器但浏览器就是按源来隔离的。协议不同也一样。页面是http://接口是https://哪怕域名端口完全一样也算跨域。这个稍微少见一点但混合内容场景下确实会碰到。顺便说一个容易误会的点同源策略不是服务器限制是浏览器的安全策略。服务端完全有资格处理来自任何来源的请求比如你用 curl 去请求它不会拦你。只有浏览器这个“中间人”会按照同源规则检查响应头发现不合规就直接把响应丢进 black hole前端拿不到任何数据只能在 console 里看到一句刺眼的blocked by CORS policy。1.2 浏览器为什么要做同源限制有人觉得同源策略很烦但它是现代 Web 安全的基础设施之一。如果没有同源限制你在一个恶意网站登录了银行账户那个网站里的脚本就能直接发请求到银行接口读取交易记录、发起转账整个过程用户无感知。浏览器做了同源限制后A 站点的 JS 默认无权访问 B 站点的数据除非 B 站点明确通过 CORS 头声明“我允许这个来源读取我的响应”。所以 CORS 可以理解为一种“服务端授权的白名单机制”响应头里的Access-Control-Allow-Origin就是服务端在告诉浏览器这个来源是我允许的你可以把数据交给它。浏览器收到这个许可之后才会把响应暴露给页面里的脚本。理解了这一层逻辑以后再遇到跨域报错心态就会好很多不是程序哪里坏了是浏览器在按规则做安全检查而我们要做的就是让服务端给出正确的许可。1.3 部分跨域请求其实“天生”不受同源限制严格来说并不是所有跨域资源都被同源策略挡住。HTML 里有些标签天然支持跨域加载最典型的就是img、link、script。页面里可以放任意域名的图片链接图片能显示可以加载任意 CDN 的 JS 文件脚本能执行。因为浏览器对这类“只读资源加载”做了豁免。这个特性直接催生了 JSONP 方案既然script标签不受跨域限制那就动态插入一个 script 标签让服务端返回一段函数调用形式的 JS前端提前注册好一个回调函数来接收数据。这个方案在十年前很流行后面我会单独讲它的适用场景和坑点。但要注意script只是能加载加载回来的脚本在页面里执行时读写其他源的数据依然受同源限制。这也是为什么 JSONP 只能 GET、不能应对现代各种复杂请求。2. CORS 机制服务端开放权限的正确姿势2.1 简单请求和预检请求CORS 并没有把跨域请求一刀切地拦住。浏览器把跨域请求分成两类简单请求和预检请求。满足以下条件的一般算简单请求请求方法只能是 GET、HEAD、POST自定义请求头很有限通常只允许Accept、Content-Type里的text/plain、multipart/form-data、application/x-www-form-urlencoded等几个值不能携带Authorization这类自定义头。实际开发中最常见的一种简单请求就是 form 表单 POST或者不带自定义头的 GET。如果请求不满足上述条件比如 POST 时 Content-Type 用了application/json或者带着Authorization请求头浏览器就会先发起一个 OPTIONS 预检请求。这个请求的目的只有一个问服务端“我准备用这种方式跨域请求你你允许吗”。服务端必须正确响应 OPTIONS并且返回对应的 CORS 头浏览器才会继续发真正的业务请求。我见过最普遍的一个坑后端的接口只处理了 GET 和 POST没有处理 OPTIONS结果所有带 JSON body 的跨域请求全部卡在预检这一环节。页面表现为请求一直转圈控制台报preflight相关错误但后端日志里根本没有对应业务请求记录。实际上 OPTIONS 请求已经打到服务端了只是服务端返回了 404 或者 405浏览器当然不会放行。2.2 CORS 响应头逐一拆解服务端要处理的响应头并不多但每个都有讲究。Access-Control-Allow-Origin是最核心的一个它决定了允许哪个源访问响应。可以设成*这表示任何人跨域都能读也可以设成具体的 Origin比如https://www.example.com。设*很方便但一旦涉及 Cookie下面的Access-Control-Allow-Credentials就不能配合*使用了必须回显具体来源。Access-Control-Allow-Methods用来声明允许的 HTTP 方法常见配置是GET, POST, PUT, DELETE, OPTIONS。如果预检请求发起的 DELETE 不在这个列表里预检就会失败。Access-Control-Allow-Headers声明允许的请求头比如前端需要带Authorization或X-Requested-With就把它们列出来。很多后端配置里漏掉了这一项导致自定义头被浏览器拦截报错文本会明确提到 missing allow header。Access-Control-Allow-Credentials是配合 Cookie 使用的值为true时表示允许跨域请求携带 Cookie。注意这个头不能是*之外的其他字符串而且一旦开启Access-Control-Allow-Origin就不能再设成*必须明确指定来源这是浏览器的硬性规则。Access-Control-Max-Age用来设置预检结果的缓存秒数。比如设成 7200浏览器在 2 小时内遇到相同条件的预检就不会再发第二次 OPTIONS直接按缓存结果放行。这个头对减少无效请求很有帮助尤其当接口被频繁调用时。还有一个响应头叫Access-Control-Expose-Headers它告诉浏览器哪些响应头可以被前端 JS 读取。默认情况下跨域响应里JavaScript 只能访问少数几个“安全列内”的响应头比如Cache-Control、Content-Language、Content-Type。如果你希望前端读取自定义响应头比如X-Total-Count就必须在服务端把它的名字加入暴露列表。2.3 携带 Cookie 的情况withCredentials 和 SameSite跨域请求带 Cookie 是另一个重灾区。前端用 fetch 或 axios 发起请求时默认不会携带目标域名的 Cookie需要在请求里开启 credentials。fetch 里写credentials: includeaxios 里写withCredentials: true。如果漏了这一步即使后端把 CORS 头全配好了Cookie 也过不去用户登录态在跨域接口上依然是失效的。后端同样要配合。Access-Control-Allow-Origin必须回显具体来源不能是*同时Access-Control-Allow-Credentials: true。这里还需要注意 Set-Cookie 本身的规则现在主流浏览器对第三方 Cookie 默认加了一个SameSite属性约束。如果后端设置的 Cookie 没有显式声明SameSiteNone; SecureChrome 在跨站场景下很可能直接拒绝存储前端代码怎么折腾都没用。这个坑非常隐蔽因为它不报 CORS 错误浏览器控制台可能只会在 Network 面板里显示请求成功了但 Application 面板里找不到对应 Cookie。排查的时候一定要检查 Set-Cookie 的完整属性跨域需要携带时后端应该这样设置Set-Cookie: tokenabc123; Path/; Domain.example.com; SameSiteNone; SecureSecure意味着 Cookie 只在 HTTPS 下生效本地开发如果用 HTTP就会碰到 Cookie 存不进去的诡异问题。很多人在这里卡很久其实把协议换成 HTTPS 或用 localhost 白名单方式处理就好。3. 开发环境与生产环境跨域方案梳理3.1 开发环境代理Vite 和 Webpack 的配置开发阶段最常见的跨域解决方案是代理。拿 Vite 举例前端跑在http://localhost:5173后端接口在http://api.example.com此时浏览器里的请求是跨域的。但 Vite 的 dev server 可以做一层转发前端发的请求地址写/api/loginVite 收到后转发到http://api.example.com/login再把响应返回给前端。对浏览器来说请求是在同一个域名同端口下完成的根本没有跨域。这样做的优势很明显前端代码不用关注跨域配置后端也不用改 CORS一切跟同源请求一样。配置写在一个vite.config.js文件里server: { proxy: { /api: { target: http://api.example.com, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }这里有个细节target可以带路径rewrite是对请求路径的修改。我的习惯是前端统一用/api开头代理时只去掉前缀后端接口直接按剩余路径匹配。这样改后端地址时只需要调整 target 一行。Webpack dev server 的逻辑类似配置项叫proxy一般在vue.config.js或webpack.config.js里devServer: { proxy: { /api: { target: http://api.example.com, changeOrigin: true } } }changeOrigin这个参数值得说一句。它会把请求头里的Host改成 target 域名否则后端服务如果做了域名校验收到请求头里的 Host 是localhost:5173很可能拒绝请求。默认情况下很多脚手架这个参数已经写好但手动搭项目时容易漏。代理方案只解决开发环境。生产环境里前端静态资源和后端接口通常部署在不同域名下依然需要正儿八经的 CORS 配置或者用下面要说的 Nginx 反代。3.2 Nginx 反向代理生产环境省心方案生产环境里我一般优先建议用 Nginx 做反向代理而不是把一堆 CORS 响应头塞在后端代码里。原因是 Nginx 接管跨域配置后端的业务代码可以保持纯净尤其适合已经有多个后端服务需要统一接入的情况。假设前端域名是www.example.com后端接口在api.internal.example.comNginx 在同一个 server 块里处理前端页面把接口路径转发到内网地址并且加上 CORS 响应头server { listen 80; server_name www.example.com; add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Authorization, Content-Type, X-Requested-With always; add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Max-Age 7200 always; if ($request_method OPTIONS) { return 204; } location /api/ { proxy_pass http://api.internal.example.com/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有三个容易踩的点。第一Access-Control-Allow-Origin用的是$http_origin也就是把请求头里的 Origin 原样回显而不是写死的*。这样可以配合Allow-Credentials: true使用Cookie 跨域才不会出问题。第二if ($request_method OPTIONS)直接返回 204。这个判断会把所有跨域预检请求在 Nginx 层终结掉不往后端转发后端接口就完全不需要感知 CORS 这件事。但要注意if在 Nginx location 里用起来要克制这里只做返回没有复杂逻辑是相对安全的写法。第三add_header后面跟了always。默认情况下 Nginx 只在响应码为 200 等成功响应里加头加了always才保证 4xx/5xx 响应也带上 CORS 头。否则很多情况下前端报错时你能在响应里看到一堆 HTML 错误页却看不到 CORS 头误导排查方向。3.3 JSONP老方案但偶尔还在用JSONPJSON with Padding是利用script跨域加载能力实现的一种“古老”跨域方案。它的原理是动态往页面里塞一个 script 标签src 指向带callback参数的接口地址服务端把数据包装成callback(data)的 JS 代码返回。因为 script 标签天然不受同源限制所以数据能顺利执行而前端预先声明好的全局函数就能拿到参数里的 data。一个最小示例前端这样写window.handleResult function (data) { console.log(拿到的数据, data); }; const script document.createElement(script); script.src https://api.example.com/getUser?callbackhandleResult; document.body.appendChild(script);后端 PHP 返回内容大致是?php $data [name 张三, avatar /avatar.png]; $callback $_GET[callback]; echo $callback . ( . json_encode($data) . ); ?浏览器拿到这段内容后把它当 JS 执行于是handleResult被调用跨域数据就拿到了。JSONP 的问题也很突出只能支持 GET没法处理 POST 等复杂请求错误处理弱脚本加载失败只能靠超时兜底callback 参数如果直接拼进输出可能造成 XSS 问题。所以现代项目中已经很少直接用它但在某些老系统、第三方开放平台比如几个地图服务的旧 API里依然存在。如果真要对接这种接口务必对 callback 参数做白名单过滤至少只允许字母数字下划线防止恶意拼接。3.4 调试用的“临时豁免”仅限本地开发除了上面几种方案还有一个写过前端的人都可能用过的歪招给浏览器加启动参数临时关闭同源策略。比如 Chrome 启动时带--disable-web-security配合一个独立的用户数据目录chrome --disable-web-security --user-data-dir/tmp/chrome-dev这样打开的浏览器里跨域请求基本不会受限制开发调试能省很多事。但这个办法只建议作为一种“临时排水”手段绝对不适合作为日常开发主力更不能进生产。为什么首先--disable-web-security会同时关闭很多其他安全机制网页里任何一个脚本都可能为非作歹如果不小心打开了真实账号风险极大。其次它掩盖了真正的 CORS 问题等你关掉参数项目一堆请求又全挂了。我一般只在快速验证某个接口返回结构时用一下正经开发一律老老实实配代理或 CORS。另外浏览器插件层面也有一些“允许跨域”类的扩展原理相当于给响应注入 CORS 头同样只适合调试别依赖它去交付项目。4. 后端实际配置参考Node、Java、PHP 我都给你列出来4.1 Node.jsExpress开启 CORSNode 生态最省事的做法是直接用cors中间件零配置就能跨域const express require(express); const cors require(cors); const app express(); app.use(cors()); app.get(/user, (req, res) { res.json({ name: 张三 }); });cors()默认会把Access-Control-Allow-Origin设成*相当于允许所有人访问。这个配置适合公开数据但不适合需要 Cookie 的登录接口。要支持 Cookie得改成app.use(cors({ origin: [https://www.example.com, https://admin.example.com], credentials: true, methods: [GET, POST, PUT, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization], maxAge: 7200 }));origin传入数组时中间件会自动判断请求里的 Origin 是否在列表里匹配时回显对应 Origin不匹配时不加 CORS 头浏览器自然拦截。这种方式比写死一个域名更灵活也比*更安全。我就是之前踩过这个坑上线后前端页面跨域请求登录接口network 显示credentials相关报错查了半天发现cors()的默认*和credentials: true冲突浏览器把整个请求都拒了。改成明确 origin 列表后立刻正常。4.2 JavaSpring Boot配置方式Spring Boot 项目配置 CORS 通常有两种路径注解式和全局配置。单个接口上可以用CrossOriginRestController public class UserController { CrossOrigin(origins https://www.example.com) GetMapping(/user) public User getUser() { return new User(张三); } }这种写法适合接口很少、只想快速给某个控制类开例外的情况。接口多了以后维护起来特别累一旦域名变更你得把所有注解翻出来改一遍。更推荐全局配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins( https://www.example.com, https://admin.example.com ) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }有一个 Spring Boot 细节allowCredentials(true)时allowedOrigins里如果出现*启动阶段可能不会报错但运行时请求会被浏览器拒日志里会看到The value of the Access-Control-Allow-Origin header must not be the wildcard *。如果要支持一组动态子域名比如*.example.com不要用allowedOrigins(https://*.example.com)Spring 5.3 之后可以改用allowedOriginPatterns方法它允许通配符并且能和 credentials 共存。还有一点如果项目里自己写了一个 Filter又同时用 WebMvcConfigurer 配置 CORS要注意可能出现两次 CORS 处理响应头重复。这种情况看起来怪但一般不影响浏览器不过最好统一用一种方式避免排查时混淆。4.3 PHP 实现跨域响应头PHP 配置 CORS 的最简单方式是在接口入口文件的顶部设置响应头?php header(Access-Control-Allow-Origin: https://www.example.com); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization); header(Access-Control-Allow-Credentials: true); header(Access-Control-Max-Age: 7200); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; } // 业务逻辑 echo json_encode([name 张三]);这个写法很直白但有两个细节要说明。对Access-Control-Allow-Origin来说如果你同时服务多个前端域名就不能写死一个而是要从请求里取Origin头放到白名单里比对命中了才回显$origin $_SERVER[HTTP_ORIGIN] ?? ; $allow [https://www.example.com, https://admin.example.com]; if (in_array($origin, $allow)) { header(Access-Control-Allow-Origin: . $origin); header(Vary: Origin); }Vary: Origin这个响应头很重要尤其当你的接口或页面被 CDN 缓存时。它告诉缓存系统“这个响应内容取决于请求里的 Origin”避免不同来源的响应被错乱缓存。很多 PHP 项目会把这点忽略掉结果前端某个来源莫名其秒拿到旧数据排查方向跑到性能优化上去了。还有刚才提到的 JSONP如果历史系统里仍然在用请注意 PHP 侧对callback参数的处理。简单拼接很容易造成反射型 XSS。至少用正则过滤$callback preg_replace(/[^a-zA-Z0-9_]/, , $_GET[callback] ?? ); echo $callback . ( . json_encode($data) . );4.4 我常用的 Response 包装器思路如果后端系统是 Java 或 Node且接口非常多不想在每个控制器里写 CORS最稳妥的做法是在网关层或全局中间件里统一处理。思路是拦截所有响应统一添加下面这些头并提前干掉落 OPTIONS 请求。以 Node Express 的中间件为例app.use((req, res, next) { const origin req.headers.origin; const whiteList [ https://www.example.com, https://admin.example.com ]; if (whiteList.includes(origin)) { res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Vary, Origin); res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-Requested-With); res.setHeader(Access-Control-Allow-Credentials, true); res.setHeader(Access-Control-Max-Age, 7200); } if (req.method OPTIONS) { return res.sendStatus(204); } next(); });这里我刻意把X-Requested-With也加进 Allow-Headers。很多老前端库比如 jQuery ajax默认会带这个头一旦漏配明明看着就是普通 GET却因为自定义头触发了预检然后又过不了报错让人一头雾水。这种统一中间件让业务接口代码零侵入后端开发不需要理解 CORS只需要知道“网关已经处理了跨域”。如果团队里后端不止一个人这种集中管理的方式能减少大量低级失误。5. 常见报错与排查实录5.1 典型 CORS 报错信息对照我在调试跨域问题时会先看报错文本里的关键词基本能判断问题方向。下面是我整理的一张速查表。报错关键词大概率原因排查方向No Access-Control-Allow-Origin header is present服务端根本没返回 CORS 头检查后端响应确认请求有没有打到正确服务Response to preflight request doesnt pass access control check预检请求失败重点看 OPTIONS 请求的响应头和状态码The value of the Access-Control-Allow-Origin header in the response must not be the wildcard *Allow-Origin是*但请求带了 Cookie改回显具体 Origin并配合Allow-Credentials: trueRequest header field authorization is not allowed by Access-Control-Allow-Headers自定义请求头不在允许列表里后端补充Authorization或其他对应头Method PUT is not allowed by Access-Control-Allow-MethodsHTTP 方法不在允许列表里检查Allow-Methods是否包含对应方法看到这些报错我的第一反应是打开浏览器 DevTools 的 Network 面板找到那条红色失败请求看它的 Request Headers、Response Headers 和 Status Code。这三块基本能定位 90% 的问题。5.2 预检请求失败的排查办法如果报错指向 preflight第一步是确认浏览器到底有没有发 OPTIONS 请求。在 Network 面板里筛选Fetch/XHR正常情况下能同时看到两条请求一条 Method 是 OPTIONS另一条才是真正的 GET/POST/PUT。OPTIONS 请求返回 2xx 代表预检通过返回 4xx/5xx 代表服务端没有正确处理。如果你在 Network 里看不到 OPTIONS 请求可能是浏览器缓存了预检结果受Access-Control-Max-Age影响也可能是这个请求被判定为简单请求压根不需要预检。需要手动模拟预检时可以用 curlcurl -i -X OPTIONS https://api.example.com/user \ -H Origin: https://www.example.com \ -H Access-Control-Request-Method: POST看响应里有没有Access-Control-Allow-Origin以及是否匹配请求里的 Origin。如果 curl 测试返回正常但浏览器还是报错多半是浏览器带的请求头比 curl 多比如Authorization不在 Allow-Headers 里。还有一种情况OPTIONS 请求被服务端的登录拦截器拦住返回了 401。前端看到的是 CORS 报错实际上问题是鉴权系统没有放行预检。这种时候需要把 OPTIONS 请求放到过滤器链的最前面不做任何业务处理直接返回成功。5.3 后端明明配了 CORS 却还是报错这个情况我遇到的频率极高也最常见。配置看着没问题代码也对但浏览器就是拦截。我一般按下面几条顺序排查。第一确认请求确实打到了你配置 CORS 的那台服务上。比如本地 nginx 转发没生效请求直接去了另一个端口那你看到的响应头根本不是预期的那套。第二检查响应头里是否有多个Access-Control-Allow-Origin。如果框架的 CORS 过滤器加了一个你自己又加了一个浏览器在读到两个不同值时也会认为策略失败。通过 DevTools 响应头区域就能直接看到。第三看看Allow-Origin的值和 Origin 头是否一模一样。差一个斜杠都会失败。比如前端是https://www.example.com/后端配置成https://www.example.com字符串不相等浏览器就不认账。用$http_origin回显能自动避免这个问题用写死域名时就要特别注意。第四检查有没有中间层把响应头吞了。有些 PHP 项目在框架里用header_remove()或者在 Nginx 里用proxy_hide_header这些操作会删掉已经设置好的 CORS 头导致后端日志里明明有浏览器收到的响应里却没有。第五如果真的带了 Cookie还要看SameSite属性。我在前面反复提到这个点因为很多人把 CORS 头全部配好后依然发现 Cookie 丢了最后定位到是 Set-Cookie 的SameSiteLax阻止了跨站携带。这是 Chrome 默认行为不是 CORS 配置错误但表现和跨域问题混在一起特别坑人。5.4 调试 CORS 的几个小技巧最后一个部分分享几个我自己实际调试时常用的小技巧。用来源无关的curl -i查看接口响应头是判断服务端配置是否正确的第一步。如果你看到响应里有完整的 CORS 头集合但浏览器还是报No Access-Control-Allow-Origin header那问题一定出在请求路径或中间代理上。两边对照能快速定位责任方。DevTools 的 Network 面板里对失败请求右键可以复制为fetch格式直接在 Console 里重放看看同样请求直接发能不能复现。很多时候你会发现浏览器发的请求和你代码里写的请求根本不是一回事比如被拦截器改写了头、加了缓存参数等。另外我习惯在本地起一个临时静态服务器来验证跨域现象而不是直接在前端项目里反复启动几百个依赖。比如在某个目录下执行npx serve -l 8080然后用http://localhost:8080打开页面去请求http://localhost:3000的接口排除前端构建工具的干扰专门验证纯浏览器环境下的 CORS 行为。这种简化环境对排查问题很有用。CORS 调试时最关键的就是保持冷静不要凭感觉改配置。按照“请求有没有发出去、响应头是什么、浏览器为什么拦、谁在中间改了响应”的顺序逐层排查大多数跨域问题都能在十分钟内定位。我个人经验是跨域报错里真正属于“无解”的极少九成以上都是配置少了一个头或者中间层干涉了响应。把基础响应头理解透遇到问题直接看报文比搜索引擎上搜十个答案都管用。
返回列表