浏览器同源策略与CORS跨域:原理、应用与调试实战指南 📅 发布时间:2026/9/13 2:38:09 👁 浏览次数: 浏览器控制台里那一行刺眼的红字做Web开发的人迟早都会撞上一次“Access to XMLHttpRequest at ‘http://api.example.com/data’ from origin ‘http://localhost:8080’ has been blocked by CORS policy…”新手第一反应是怀疑后端接口挂了后端同事看了一眼说接口没问题最后排查半天发现是“跨域”。而跨域这件事底层就是今天要聊的浏览器同源策略。它是Web安全体系里最基础、也最容易被忽略的一道防线更是前后端联调、面试笔试、CTF比赛里绕不开的高频话题。这篇文章我会把同源策略从头到尾拆开讲它到底判断的是什么东西、限制了哪些行为、为什么非得限制、实际开发里有哪些合规的跨域手段以及踩坑之后的调试套路。适合刚接触Web安全的前端/后端开发、准备安全方向面试的同学也适合所有被CORS报错折磨过的人。1. 同源策略到底是什么先别急着背概念我们从一次真实请求开始推演。假设你现在打开了一个页面地址是https://www.example.com/login。这个页面里的JavaScript发起了一个请求要访问https://www.example.com/api/userinfo浏览器检查了一下页面地址和请求地址的协议、域名、端口都一样于是放行。但如果这个请求指向的是https://api.another.com/data协议一样、域名不一样浏览器就会启动拦截机制把响应挡在JavaScript的读取范围之外。这个“检查三个关键要素是否一致”的机制就是同源策略Same-Origin Policy简称SOP。1.1 同源的精确判定规则同源策略里的“源”不是指一个完整URL而是URL最前面的三元组协议Protocolhttp、https、file、chrome-extension等主机Host域名或IP地址端口Porthttp默认80https默认443只有当两个URL的协议、主机、端口完全一致时才被认为是同源。我在带新人的时候经常让他们把下面这个表格抄一遍页面URL请求URL是否同源原因https://www.example.com/pagehttps://www.example.com/other同源协议、域名、端口全一致http://www.example.com/pagehttps://www.example.com/other不同源协议不同http vs httpshttps://www.example.com/pagehttps://api.example.com/other不同源子域名不同https://www.example.com:8080/pagehttps://www.example.com/other不同源端口不同https://www.example.com/pagehttps://www.example.com.cn/other不同源域名完全不同我自己早期犯过一个低级错误把https://www.example.com和https://example.com当成同一个源。实际上带不带www对浏览器来说就是完全不同的两个源哪怕它们解析出来的IP一模一样。1.2 几个容易踩的判定坑默认端口带来的错觉https://www.example.com和https://www.example.com:443是同源的因为443是HTTPS的默认端口浏览器会自动补全。同理http://www.example.com和http://www.example.com:80也是同源的。但如果你写成了https://www.example.com:8443即使这个服务确实是同一个应用浏览器也会判定为跨域因为端口不一致。IP和域名是两回事localhost、127.0.0.1、本机局域网IP比如192.168.1.100指向的可能是同一台机器但在同源判定里它们是不同的host因此是不同源。开发环境里最常见的跨域场景就是前端跑在localhost:8080后端跑在127.0.0.1:8081看起来都在本机但一个端口不同、一个主机表述不同妥妥的跨域。协议不同是硬伤HTTP和HTTPS之间天然不同源。在HTTPS页面里要请求HTTP接口除了跨域之外还会遇到混合内容Mixed Content限制浏览器干脆直接阻止。反过来HTTP页面里请求HTTPS接口虽然不会被混合内容规则拦但依然受同源策略约束。注意同源判定只针对“浏览器环境”。在Node.js、Python、Go这类服务端环境里并没有同源策略这个概念服务端之间的请求不受SOP约束。这也是为什么跨域问题主要出现在浏览器前端联调阶段。1.3 同源策略是一种“浏览器行为”这里要强调一个容易被误解的点同源策略是浏览器实现的安全策略不是HTTP协议的一部分也不是服务器必须执行的规则。服务器并不知道请求是不是跨域的它照样会处理并返回数据。拦截行为发生在浏览器这一侧——响应到达浏览器以后浏览器检查发现跨域且没有合法的跨域头就把响应“扣下来”不让页面的JavaScript读取。所以你在开发者工具Network面板里经常能看到请求状态码是200但控制台照样报CORS错误这就是“请求发出去了响应也回来了但浏览器不让读”的典型表现。理解这一点很重要后面讲CORS跨域方案时你会深刻体会到什么叫“服务端配合浏览器放行”。2. 同源策略具体限制了哪些行为很多初学者以为同源策略只是限制网络请求这个理解太窄了。同源策略在浏览器里是一整套隔离机制主要管三件事DOM访问、Cookie和Storage隔离、网络请求读取。2.1 DOM访问限制一个页面里可以嵌入另一个页面比如用iframe。如果没有同源策略恶意页面就可以通过JavaScript读取被嵌入页面的DOM结构、修改表单数据、监听点击事件甚至窃取用户在里面的输入内容。比如我在自己的博客页面上用iframe嵌入了邮箱页面如果两者不同源我是无法拿到邮箱页面内部任何DOM元素的。强行访问的话控制台会报错Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.这条限制保护的是页面“内部结构”的不可见性。不同源的两个页面之间只有Window对象上一些安全的属性比如location的只读部分、window.length等可以访问其他一律禁止。2.2 Cookie和Storage隔离同源策略还管着浏览器本地存储的读写边界。Cookie在设置时虽然可以指定Domain属性来实现跨子域共享但默认情况下www.example.com写入的Cookieapi.example.com是拿不到的。localStorage和sessionStorage更是严格按源来隔离的连子域之间都不能互相读因为Storage规范里没有像Cookie那样提供共享开关。这意味着什么你登录了A网站浏览器里存了A的会话Cookie然后你又打开了恶意网站B。B的脚本想读取你存在A.com下的Cookie浏览器不会给它这个权限。这就是Cookie隔离能阻止“直接偷登录态”的原因。不过要注意Cookie的隔离是“脚本读取层面”的隔离不是“请求发送层面”的隔离。这句话是后面理解CSRF攻击的钥匙。2.3 网络请求限制网络请求这块最容易被混淆严格来说同源策略限制的是“读取跨域响应”而不是“发起跨域请求”。页面上发一个跨域请求能不能发出去分情况script src、img src、link href、form action、iframe src这类标签形式的请求可以正常发起响应也能被浏览器接收只不过接收方是标签自个儿脚本不直接能读。这是历史遗留设计早期Web就是靠这种方式引用外部资源。fetch、XMLHttpRequestXHR这类由JavaScript发起的网络请求跨域时能否成功“读取”响应取决于目标服务有没有返回正确的CORS响应头。注意是“读取”受限不是“发送”受限。打个比方你家大门装了一道门禁快递员请求可以走到门口发送门卫浏览器验明身份后要么把快递交给你放行读取要么拒收退回拦截响应。在CORS配置缺失时快递已经到了但门卫直接跟你说“这快递不能收”。2.4 同源策略不管什么说完了限制再说说不受同源策略管的部分这部分是后面做跨域方案和攻击手法的“漏洞面”标签加载类请求不受SOP约束script、img、link、iframe、video、audio、form提交等。重定向不算跨域拦截浏览器地址栏里跳转到任意站点都是允许的。window.open打开新页面、location.href跳转也不受SOP限制。就是这些“不管”衍生了JSONP跨域方案也衍生了CSRF攻击、JSONP劫持这类Web安全漏洞。3. 为什么要限制跨域开放会出什么事很多人觉得同源策略烦人处处挡路。但你得先搞清楚如果没有同源策略整个Web的信任模型会瞬间崩塌。3.1 没有SOP的世界会是怎样做一个思想实验。假设你在网上银行页面里登录了账号浏览器存了银行的会话Cookie然后你又在另一个标签页打开了攻击者的网站。如果没有同源策略攻击者网站里的恶意脚本就可以干两件大事第一直接发一个跨域请求到你银行的转账接口浏览器自动携带你登录银行站点时的Cookie银行服务器一看请求里有合法会话就执行转账操作。第二恶意脚本还可以发起一个跨域请求读取你的银行余额和交易记录因为同源限制没了响应内容全部暴露。第一种攻击形态就是CSRF跨站请求伪造第二种更狠直接把数据偷走了。而有了同源策略之后第二种被直接拦截第一种虽然请求能发出但恶意脚本也看不到响应结果攻击者没法拿到有效数据危害被大大降低。3.2 CSRF和SOP的关系这里有个经典的细节问题既然SOP拦了跨域请求的“读”为什么CSRF仍然存在原因是现代Web应用的很多“写操作”接口恰恰允许通过跨域方式提交。比如用户修改个人信息、发帖、转账这类操作往往只要请求能到服务器、Cookie能自动带上服务器就可能执行。SOP只拦截了“跨域读取响应”并没有拦截“跨域提交请求”。所以CSRF防御思路从来不是靠SOP而是靠服务端校验一个攻击者无法读取的秘密——比如CSRF Token、SameSite Cookie属性、校验Origin/Referer头。这也是我在面试中特别喜欢问的一个点同源策略能防CSRF吗答案是不能完全防它只让攻击者“看不到结果”但攻击可能已经发生了。3.3 合法业务为什么需要跨域既然跨域这么危险为什么不做死因为互联网业务形态决定了跨域是刚需前后端分离架构前端页面部署在CDN或Web服务器上后端API独立部署两者域名天然不同。第三方开放平台比如小程序、开放平台上的第三方应用需要调用平台API。多子域体系当一个公司在example.com下运行了login.example.com、mail.example.com、api.example.com等多个子系统时系统之间可能需要共享登录态或互调接口。同源策略的本质是“默认拒绝、按需放行”而不是“一刀切禁止”。既然业务有跨域需求浏览器和Web标准就设计出了一套可控的放行机制——这就是CORS。4. 实际开发中如何合法地跨域跨域方案我列一个总览表你写代码前先对照着选型方案适用场景原理安全性JSONP只支持GET请求的老旧接口借助script标签不受SOP限制较低可能被劫持CORS现代API的标准方案服务端通过响应头声明放行规则较高的灵活性和可控性postMessageiframe或窗口之间的跨域通信通过事件机制传递消息需自己校验消息来源document.domain同一主域下的子域互访降低域到相同主域只适用于子域场景代理转发前端开发环境或生产隐藏API请求发到同源地址由服务端转发较好且不影响原有架构4.1 JSONP方案JSONP的诞生比CORS早很多当时浏览器还没有CORS标准前端为了解决跨域想出的野路子。它的核心思路是script标签加载外部JS文件不受同源限制那我干脆用script标签的src去请求接口把数据塞在一个回调函数里返回。前端提前定义一个全局函数脚本加载后自动执行这个函数数据就“传”过来了。前端代码像这样script function handleData(data) { console.log(获得的用户信息:, data); } /script script srchttps://api.example.com/userinfo?callbackhandleData/script后端收到请求后不返回普通JSON而是返回一段可执行的JavaScripthandleData({name: admin, id: 1001});浏览器加载这段脚本后自动执行handleData函数参数就是服务端拼好的数据对象。前端就这样绕过了同源限制。JSONP的痛点也很明显我在项目里基本不用它做新功能只支持GET请求没法用POST、PUT、DELETE。用全局函数容易造成命名冲突和XSS注入风险。没有标准的错误处理机制请求失败了很难排查。现在CORS已经覆盖所有主流浏览器JSONP只剩兼容老系统的价值。4.2 CORS方案重点CORS全称是Cross-Origin Resource Sharing跨源资源共享。它是W3C标准也是目前跨域方案的主流。CORS的设计思路和JSONP完全不同不是绕过SOP而是在SOP基础上建立一套“白名单机制”。服务端在HTTP响应头里告诉浏览器我这个资源允许哪些来源访问、允许哪些方法、允许哪些请求头。核心响应头就这几个Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true Access-Control-Max-Age: 600Access-Control-Allow-Origin最重要指定允许的来源。可以是具体域名也可以是通配符*表示所有来源。Access-Control-Allow-Methods允许的HTTP方法。Access-Control-Allow-Headers允许的请求头预检请求时需要。Access-Control-Allow-Credentials是否允许携带Cookie。Access-Control-Max-Age预检请求结果可以缓存的时长单位秒。简单请求和预检请求CORS把跨域请求分成两类简单请求Simple Request和预检请求Preflighted Request。满足以下所有条件才算简单请求请求方法只能是GET、HEAD、POST。请求头只能包含常规字段如Accept、Content-Type、Accept-Language以及部分安全字段。Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain这三种之一。如果你的请求用了application/json作为Content-Type或者带了Authorization这种自定义头或者用了PUT/DELETE方法浏览器会先发一个OPTIONS预检请求去问服务器“你这个接口允许我这样请求吗”服务器返回明确的允许规则后浏览器才发真正的业务请求。这也是很多人踩坑的地方后端只处理了业务接口没处理OPTIONS请求导致浏览器预检阶段就失败业务请求根本没发出去。一个典型的预检请求长这样OPTIONS /api/userinfo HTTP/1.1 Host: api.example.com Origin: https://www.example.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: Content-Type服务器要针对OPTIONS请求返回正确的响应头HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 600服务端到底该怎么配以Java的Spring Boot为例可以写一个CORS过滤器统一处理Component public class CorsFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Access-Control-Allow-Origin, https://www.example.com); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); resp.setHeader(Access-Control-Allow-Credentials, true); if (OPTIONS.equalsIgnoreCase(((HttpServletRequest) request).getMethod())) { resp.setStatus(HttpServletResponse.SC_NO_CONTENT); return; } chain.doFilter(request, response); } }用Nginx反代时也可以直接在代理层配置CORS头location /api/ { add_header Access-Control-Allow-Origin https://www.example.com; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; add_header Access-Control-Allow-Credentials true; if ($request_method OPTIONS) { return 204; } proxy_pass http://backend_server; }4.3 带Cookie的CORS配置跨域请求要带Cookie时坑最多。前端要设置credentials后端也要打开对应开关。前端fetchfetch(https://api.example.com/userinfo, { method: GET, credentials: include });注意credentials: include是跨域携带Cookie的开关如果只写credentials: same-origin跨域请求是不带Cookie的。服务端响应头必须同时满足两个条件Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Credentials: true而且此时Access-Control-Allow-Origin不能使用通配符*必须指定具体的来源域名。如果服务端返回的是*浏览器会直接拒绝这次请求报错信息里一般会写“The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include。”我在项目里曾经直接把Allow-Origin写成*结果无论怎么调前端都不带Cookie排查了半小时才反应过来是这个通配符的问题。4.4 postMessage跨域通信如果你只是想在一个页面里和不同源的iframe通信用CORS反而不方便window.postMessage是更直接的手段。父页面发送消息const iframe document.getElementById(childFrame); iframe.contentWindow.postMessage(父页面发来消息, https://child.example.com);子页面监听消息window.addEventListener(message, function (event) { // 这里一定要校验来源否则任何网站发的消息都能收到 if (event.origin ! https://parent.example.com) { return; } console.log(收到父页面消息:, event.data); event.source.postMessage(子页面收到, event.origin); });postMessage的安全点全在接收方必须校验event.origin否则攻击者可以给自己的页面嵌入一个恶意iframe向你的窗口发消息诱导你的代码执行危险动作。这属于“没有身份验证的消息通道”要用好它就得把每条消息当成陌生人的话来对待。4.5 document.domain降域这个方案适用范围很窄只能用于同一个主域下的不同子域。比如aaa.example.com和bbb.example.com它们天然不同源但两个页面都执行document.domain example.com;之后它们互相访问时就被视为同源。必须提醒一下document.domain只能设置为当前域名本身或它的父级域名不能设置为一个无关的域名。而且这个机制只对嵌套iframe之间的DOM访问有效对XHR/fetch的跨域限制没有帮助。在实际安全评估里document.domain赋值经常被攻击者用来配合iframe加载子域页面进行会话固定攻击所以我个人建议能用CORS解决就不要用这个方案。4.6 代理转发开发环境和生产实战的首选代理转发思路特别简单我让浏览器只请求同源地址由同源的服务器代替前端去请求真正的目标接口然后把响应原样返回。前端调/api/userinfoNginx收到后反向代理到http://api.example.com:8080/userinfo浏览器全程不知道自己跨域了。这样根本没有跨域请求发生也就不存在CORS问题了。开发环境用Vite做代理// vite.config.js export default { server: { proxy: { /api: { target: http://api.example.com:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } };用webpack开发服务器的话在webpack.config.js里配置// webpack.config.js module.exports { devServer: { proxy: { /api: http://api.example.com:8080 } } };这个方案的好处是前端代码不用写任何跨域逻辑生产环境也能用Nginx统一处理还能顺便隐藏后端真实地址、做负载均衡和请求日志。缺点是所有流量都经过代理层代理服务器自身要保证高可用。5. 常见问题与排查技巧实录跨域问题看着复杂排查起来其实有固定套路。我把实战里最常见的几个问题整理成速查表你直接照着对照。5.1 经典报错对照速查报错信息片段原因解决方向has been blocked by CORS policy: No Access-Control-Allow-Origin header is present服务端没有在响应头里返回CORS头服务端必须配置Access-Control-Allow-Origin等字段must not be the wildcard * when the requests credentials mode is include前端开了credentials后端却返回*后端把Allow-Origin改成具体来源域名Response to preflight request doesnt pass access control check预检请求OPTIONS没有通过确保服务端正确处理OPTIONS并返回允许的Method/HeaderBlocked a frame from accessing a cross-origin frame跨域iframe之间访问DOM被拦截改由postMessage通信或同源部署Invalid Access-Control-Allow-Origin header响应头里的Allow-Origin值格式错误检查是否有多个值或包含换行符、空格5.2 排查第一步看Network面板而不是光看console控制台报错往往只给结论给不了证据。我接到跨域相关的问题后第一件事永远是打开DevTools的Network面板重新触发一次请求然后看两个东西第一个是请求是否真的发出去了。跨域请求如果被CORS拦截你依然能在Network里看到它状态码可能是200或403说明不是请求的问题而是响应校验的问题。如果连请求的影子都没有那就去看是不是被预检卡住了。第二个是点击该请求看响应头Response Headers里有没有Access-Control-Allow-Origin这一项。没有就是服务端压根没配有但值不对就看具体值和页面Origin对不对得上。另外注意预检请求。当你使用了application/json或自定义头时Network面板里会先出现一条OPTIONS请求。选中它重点看响应头的Access-Control-Allow-Methods和Access-Control-Allow-Headers里有没有包含正式请求要用的方法和请求头。5.3 服务端怎么快速验证CORS配置是否生效不用麻烦前端同学联调你在服务器上直接用curl就能验证curl -i -X OPTIONS http://api.example.com/userinfo \ -H Origin: https://www.example.com \ -H Access-Control-Request-Method: PUT \ -H Access-Control-Request-Headers: Content-Type看返回的头里有没有预期放行规则。再测一个GET请求curl -i -X GET http://api.example.com/userinfo \ -H Origin: https://www.example.com重点看响应头里有没有Access-Control-Allow-Origin。如果第一条OPTIONS返回204、头齐全第二条GET返回200且带上Allow-Origin那服务端基本没问题。5.4 我踩过的三个真实深坑第一个坑是Nginx里重复的add_header。Nginx有个特性如果一个location块里使用了add_header那么它只会继承外层所有的add_header如果你在内层又加了一个外层的其他add_header会被吞掉。比如外层配了add_header Access-Control-Allow-Origin内层又写了add_header X-Frame-Options那外层那个跨域头可能就不生效了真是血泪教训。第二个坑是后端返回的Access-Control-Allow-Origin和当前请求不匹配。有个后端同事图省事直接写死了某个测试域名结果线上前端域名换了所有跨域请求全部失效。排查到后面发现接口其实通了就是Allow-Origin对不上。最好的实践是由后端动态读取请求的Origin头然后校验是否在项目白名单里再回写同一个Origin值这样既解决多域名问题也不会暴露过大的跨域面。第三个坑是自定义请求头导致的“隐式预检”。前端写了一个X-Requested-With: XMLHttpRequest或者第三方SDK加了Authorization头服务端没在Access-Control-Allow-Headers里声明浏览器预检直接失败。你看到的现象就是按钮点了没反应控制台也没有明显的红色报错因为有些框架把错误吞了只在Network里能看到一条OPTIONS请求状态异常。这种问题排查起来最头疼我是靠抓包看请求列表才发现多了一条OPTIONS。5.5 CTF比赛里同源策略怎么考这点顺带提一下CTF的Web方向特别爱在同源策略上做文章。最常见的是CSRF类型的题目给你一个正常站点和一个攻击者页面让你构造一个自动提交的跨域请求配合受害者登录态去改密码、发帖、删数据。这类题的重点就是“请求能发出去、Cookie能自动带上去”因为SOP拦的是响应读取不拦提交。第二种常见的是JSONP劫持。题目给一个返回callback参数的接口服务端没有校验来源就返回用户敏感信息你要能利用script标签跨域加载这个接口把数据传到自己的服务器上。这考的其实就是“script标签不受SOP限制”这条特性。第三种是SOP绕过技巧。某些场景下攻击者通过window.name、location.href跳转后的历史记录、postMessage配置不当等途径间接读取跨域页面的信息。本质上都是在挑战“浏览器把哪些信息暴露给了跨域页面”的边界。对CTF感兴趣的读者学完同源策略之后建议去做几道CSRF和JSONP劫持的入门题能瞬间把这些知识点串起来。最后再分享一个我自己带新人时常用的测试习惯写前端之前先打开浏览器开发者工具在Console里执行一个最简单的fetch请求确认目标接口的跨域策略再动手可以省掉后面大半的联调时间fetch(http://api.example.com/userinfo, { method: GET, credentials: include }).then(r r.json()).then(console.log).catch(console.error);如果这行代码在目标页面里能跑通CORS这一关就已经过了如果报错直接从Network面板看响应头缺什么按上面那张速查表逐项排查就行。同源策略本身不难难的是把浏览器、服务端、代理层这三个环节里各自的职责边界搞清楚。这也是我把它放在Web安全系列第一篇的原因后面聊XSS、CSRF、点击劫持这些攻击手法时本质上都是在跟这一套“默认拒绝、按需放行”的浏览器策略打交道。