浏览器的同源策略

同源策略(Same-Origin Policy)是浏览器安全的基石,由 Netscape 公司于 1995 年引入。虽然大部分前端开发者都知道这个概念,但了解往往停留在表面。本文带你全面理解同源策略的运作机制、限制范围,以及日常开发中绕不过的各种跨域解决方案。

一、同源的定义

如果两个 URL 的 协议(protocol)、主机(host)、端口(port) 都相同,则这两个 URL 同源。这个三元组也被称为"协议/主机/端口元组"。

下面以 http://store.company.com/dir/page.html 为基准进行源对比:

URL 结果 原因
http://store.company.com/dir2/other.html 同源 只有路径不同
http://store.company.com/dir/inner/another.html 同源 只有路径不同
https://store.company.com/secure.html 不同源 协议不同(http vs https)
http://store.company.com:81/dir/etc.html 不同源 端口不同(80 vs 81)
http://news.company.com/dir/other.html 不同源 主机不同

几个容易混淆的特殊情况

  • http://store.company.com 和 http://store.company.com:80 同源吗? —— 是的。HTTP 默认端口就是 80,显式写出和省略等价。同理,HTTPS 默认 443。
  • about:blank 和 javascript: URL —— 继承创建它的文档的源。
  • data: URL —— 会得到一个独立、不透明的源,与任何页面都不同源。
  • file:// 协议 —— 各浏览器实现不一致。Chrome 视所有 file:// 页面为同源,但实际开发中应避免依赖此行为。

判定口诀:路径随便变,端口看默认,协议主机差一点都不行。


二、同源策略限制了什么?

同源策略并非"禁止一切跨域通信",而是分场景设限。理解它限制什么、不限制什么,才能精准选择跨域方案。

2.1 DOM 访问受限

不同源的页面无法通过 JavaScript 访问彼此的 DOM。最典型场景:a.com 的页面嵌入了 <iframe src="http://b.com">,a.com 的脚本无法读取 iframe 内部的 document。

// a.com 页面中
const iframe = document.getElementById('other-site');
try {
  const doc = iframe.contentDocument; // 抛出跨域异常
} catch (e) {
  console.log('同源策略阻止了 DOM 访问');
}

2.2 存储隔离

Cookie、localStorage、IndexedDB 都是以源为隔离单位。a.com 无法读写 b.com 下的任何存储数据。

2.3 AJAX 请求受限

这是开发者最常遇到的跨域问题——通过 XMLHttpRequest 或 fetch 向不同源的服务器发送请求,浏览器会拦截响应。

// 在 http://localhost:3000 页面中
fetch('http://api.example.com/data')
  .then(res => res.json())
  .catch(err => console.error('跨域请求被拦截:', err));

浏览器控制台会看到经典的报错:

Access to fetch at 'http://api.example.com/data' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

2.4 不受限的例外

以下标签的资源加载不受同源策略限制(否则 Web 根本无法运转):

  • <script src="..."> —— JS 脚本
  • <img src="..."> —— 图片
  • <link rel="stylesheet" href="..."> —— CSS 样式表
  • <video> 和 <audio> —— 媒体资源
  • <iframe> 的加载本身不受限(加载后 DOM 访问才受限)
  • <form> 的跨域提交不受限

这些例外是很多跨域方案(如 JSONP)的底层基础,同时也是 XSS 和 CSRF 攻击的温床——浏览器不限制资源加载,只限制 JavaScript 读取加载后的内容。


三、跨域解决方案

3.1 CORS —— 官方标准方案

CORS(Cross-Origin Resource Sharing,跨域资源共享)是 W3C 于 2014 年正式推荐的跨域标准。它在 HTTP 层面增加了一系列响应头,让服务器主动声明「哪些源可以访问我」。

服务端设置(Express 示例):

const express = require('express');
const app = express();

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Max-Age', '86400');
  next();
});

简单请求 vs 预检请求

浏览器将跨域请求分为两类:

简单请求(同时满足以下三个条件,不触发 OPTIONS 预检):

  • 方法为 GET、HEAD、POST 之一
  • 仅使用 Accept、Accept-Language、Content-Language、Content-Type(仅限 text/plain、multipart/form-data、application/x-www-form-urlencoded)等安全头
  • 没有注册 XMLHttpRequestUpload 的事件监听器

预检请求:不满足上述任一条件时,浏览器先发一个 OPTIONS 请求「探路」,服务器返回允许的方法和头,浏览器确认后再发真正的请求。

// 这个 POST 会触发预检,因为 Content-Type 是 application/json
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'test' })
});

携带 Cookie

默认情况下,跨域请求不发送 Cookie。如需发送,需要三个条件同时满足:

// 客户端
fetch('https://api.example.com/user', {
  credentials: 'include'
});

// 服务端
res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com'); // 不能是 *
res.setHeader('Access-Control-Allow-Credentials', 'true');

3.2 JSONP —— 利用 script 标签的例外

JSONP 的原理:script 标签不受同源策略限制,可以加载任意域的 JS 文件。服务端返回的不是 JSON 数据,而是一段「函数调用」形式的 JS 代码。

// 客户端
function handleData(data) {
  console.log('收到数据:', data);
}

const script = document.createElement('script');
script.src = 'http://api.example.com/data?callback=handleData';
document.body.appendChild(script);
// 服务端(Express)
app.get('/data', (req, res) => {
  const callback = req.query.callback;
  const data = { name: 'test', value: 123 };
  res.send(`${callback}(${JSON.stringify(data)})`);
});

JSONP 的致命缺陷:

  • 只支持 GET 请求
  • 无法设置自定义请求头
  • 没有错误处理机制
  • 存在 XSS 安全风险——你完全信任返回 JS 代码的第三方服务端

结论:2019 年,JSONP 仅适合用于可控的第三方接口,内部 API 应该全部使用 CORS。

3.3 postMessage —— iframe 通信标准方案

postMessage 是 HTML5 引入的跨文档通信 API,允许不同源的窗口之间安全地传递消息。

<!-- 父页面 http://parent.com -->
<iframe id="child" src="http://child.com"></iframe>
<script>
  const iframe = document.getElementById('child');
  iframe.contentWindow.postMessage(
    { type: 'greeting', text: 'Hello from parent' },
    'http://child.com'
  );
  window.addEventListener('message', (event) => {
    if (event.origin !== 'http://child.com') return;
    console.log('收到子页面消息:', event.data);
  });
</script>
<!-- 子页面 http://child.com -->
<script>
  window.addEventListener('message', (event) => {
    if (event.origin !== 'http://parent.com') return;
    console.log('收到父页面消息:', event.data);
    event.source.postMessage(
      { type: 'reply', text: 'Hello from child' },
      event.origin
    );
  });
</script>

安全要点:处理 message 事件时,永远要校验 event.origin。

3.4 WebSocket —— 不受同源策略约束

WebSocket 协议在设计上不受同源策略限制。

const socket = new WebSocket('wss://chat.example.com');
socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'join', room: 'general' }));
};
socket.onmessage = (event) => {
  console.log('收到消息:', JSON.parse(event.data));
};

虽然浏览器不限制 WebSocket 的跨域连接,但服务端应在握手阶段校验 Origin 头。

3.5 Nginx 反向代理 —— 生产环境首选

最彻底的跨域解决方案:让前端和 API 部署在同一个域下。

server {
    listen 80;
    server_name myapp.com;

    location / {
        root /var/www/frontend;
        try_files $uri /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;
    }
}

四、Content Security Policy(CSP)

4.1 CSP 和 SOP 的关系

  • SOP:浏览器自动执行,默认拒绝跨域访问 DOM、存储和 AJAX 响应
  • CSP:服务端通过 HTTP 头声明,告诉浏览器哪些来源的资源可以被加载和执行

CSP 最核心的应用场景是防御 XSS 攻击。

4.2 核心指令

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' https://images.example.com data:;
  connect-src 'self' https://api.example.com;
  font-src https://fonts.example.com;
  frame-ancestors 'none';
  report-uri /csp-report
指令 作用
default-src 所有资源类型的默认策略
script-src 允许执行的 JS 来源
style-src 允许加载的 CSS 来源
img-src 允许加载的图片来源
connect-src 允许 fetch/WebSocket 连接的目标
font-src 允许加载的字体来源

4.3 报告模式

正式上线前先观察:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

4.4 生产配置

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
" always;

五、开发与生产中的跨域实践

5.1 本地开发:webpack-dev-server proxy

// webpack.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: { '^/api': '' }
      }
    }
  }
};

5.2 生产环境:统一域名部署

Nginx 反向代理——前端静态文件和 API 网关放在同一个域下。

5.3 CDN 资源的跨域问题

location ~* \.(ttf|otf|woff|woff2|eot)$ {
    add_header Access-Control-Allow-Origin *;
}
<link rel="preload" href="https://cdn.example.com/fonts/MainFont.woff2"
      as="font" type="font/woff2" crossorigin>

六、安全底线

铁律一

绝对不要 Access-Control-Allow-Origin: * 配合 Access-Control-Allow-Credentials: true。

铁律二

服务端应始终验证 Origin 或 Referer 头。

铁律三

JSONP 仅用于可信任的第三方公共服务接口。内部业务 API 禁用 JSONP。


总结

跨域问题的解决思路本质上只有两条路:要么让服务器声明信任(CORS),要么让浏览器感觉不到跨域(反向代理)。

场景 推荐方案
自己的前端调用自己的 API Nginx 反向代理
本地开发 webpack-dev-server proxy
提供给第三方调用 CORS + 白名单
跨窗口通信(iframe) postMessage + origin 校验
实时双向通信 WebSocket(服务端检查 Origin)
防御 XSS 注入 CSP 头