浏览器会同时加载并执行来自多个站点的脚本。如果没有隔离,一段脚本就能读取另一个站点的响应内容、访问其页面中的节点,并把数据发往任意位置。同源策略(Same-Origin Policy)正是浏览器为切断这条路径而施加的隔离规则:它不阻止请求发出,而是限制一个源的文档能否读取另一个源的资源。
要理解这套规则,需要依次回答三个问题:源如何计算、哪些能力受到限制、CORS 又是如何在此基础上放宽限制的。
源的判定
源由协议、主机、端口三部分构成,任意一项不同,两个源就互不相同。
| 页面地址 | 目标地址 | 是否同源 | 判定依据 |
|---|---|---|---|
https://a.example.com/x | https://a.example.com/y | 同源 | 三项完全一致 |
https://a.example.com | http://a.example.com | 跨源 | 协议不同 |
https://a.example.com | https://b.example.com | 跨源 | 主机不同 |
https://a.example.com | https://a.example.com:8443 | 跨源 | 端口不同 |
https://a.example.com | https://a.example.com:443 | 同源 | 省略端口时按协议默认值补齐 |
主机名比较时不区分大小写;端口省略时按协议默认端口补齐,http 为 80、https 为 443。同主域下的子域之间互不相同,a.example.com 与 b.example.com 是两个独立的源。早期 document.domain 可以把脚本的源降级到可注册域,从而让同主域的多个子域互通,该做法在较新的实现中已被弱化或移除。
受限的访问范围
同源策略限制的是「读取」,而不是「发送」。逐项来看:
- DOM:跨源 iframe 的
contentWindow可以拿到引用,但访问其文档内部的节点会被拒绝;postMessage提供了受控的通信通道。 - 网络响应:
fetch与XMLHttpRequest默认不能读取跨源响应体,请求本身仍会发出。 - Cookie:Cookie 的可见性由
Domain、Path、Secure、SameSite等属性决定,与源并非一一对应,属于另一套独立规则。 - 存储:
localStorage、sessionStorage、IndexedDB 按源隔离,脚本读不到其他源写入的数据。 - 画布像素:把跨源图像绘制到 canvas 后,调用
getImageData会因画布被标记为污染而失败。
需要注意的是,跨源请求可能带有副作用:表单提交与简单请求依然会把数据发到服务器,服务端难以从请求本身判断它源自哪个页面。这类问题通常由 CSRF 令牌与 SameSite 属性处理。
CORS:简单请求与预检
跨源资源共享(CORS)是一套基于响应头的授权机制:服务端显式声明允许哪些源访问,浏览器据此决定是否把响应交给脚本。
满足下列条件的请求按「简单请求」处理,直接发出,不产生额外往返:
- 方法是
GET、HEAD或POST - 请求头只包含 CORS 安全列表中的字段,如
Accept、Accept-Language、Content-Language Content-Type只取application/x-www-form-urlencoded、multipart/form-data、text/plain三者之一
不满足上述条件的请求,浏览器会先发出一个 OPTIONS 预检请求,询问服务端是否允许实际请求:
OPTIONS /api/data HTTP/1.1
Origin: https://a.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-token
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://a.example.com
Access-Control-Allow-Methods: GET, PUT
Access-Control-Allow-Headers: content-type, x-token
Access-Control-Max-Age: 600
预检通过后,浏览器才发送实际请求,并在收到响应时再次校验 Access-Control-Allow-Origin。Access-Control-Max-Age 给出预检结果可被缓存的时间,用来减少重复的 OPTIONS 往返。
响应头的语义
| 响应头 | 作用 | 说明 |
|---|---|---|
Access-Control-Allow-Origin | 声明允许的源 | 可取具体源或 *,携带凭证时不能为 * |
Access-Control-Allow-Methods | 预检允许的方法 | 只在预检响应中校验 |
Access-Control-Allow-Headers | 预检允许的请求头 | 用于非安全列表字段 |
Access-Control-Expose-Headers | 暴露给脚本的响应头 | 未列出的响应头脚本读不到 |
Access-Control-Allow-Credentials | 是否接受凭证 | 为 true 时允许携带 Cookie |
Access-Control-Max-Age | 预检缓存时长 | 由浏览器维护,各实现的上限不同 |
携带凭证的请求
当请求以 credentials: 'include' 发出时,浏览器会附带 Cookie 与 HTTP 认证信息。此时服务端必须返回具体的源而非常量 *,并同时返回 Access-Control-Allow-Credentials: true,否则浏览器会拒绝把响应交给脚本。带凭证的跨源请求还受 Cookie 自身规则约束:若 Cookie 的 SameSite 取 Lax 或 Strict,默认不会随跨源请求发出。
JSONP 的历史成因与局限
在 CORS 尚未被广泛支持之前,页面常用 JSONP 绕过限制:<script> 的加载不受同源策略约束,因此把请求结果包装成对回调函数的调用,即可取回数据。
// 页面预先定义回调
function handle(payload) { console.log(payload); }
// <script src="https://api.example.com/data?cb=handle"></script>
JSONP 的局限来自它的本质:它执行的是第三方脚本,等价于把该域的代码引入页面,出错时也拿不到状态码;它只能发 GET 请求;回调名与参数需要双方约定。随着 CORS 的普及,多数场景已由 CORS 承担,JSONP 更多作为历史方案存在。
小结
同源策略以「协议 + 主机 + 端口」为隔离单位,限制跨源读取而非请求发送。CORS 通过一组响应头把授权决策交回服务端:简单请求直接发出并在响应上校验,非简单请求先经 OPTIONS 预检。理解这条链路,有助于排查跨源报错与凭证不生效等问题,也能与 Service Worker 拦截请求的机制对照来看。