跨域与同源策略

17 分钟

一、同源策略

1.1 什么是同源

同源策略(Same-Origin Policy)是浏览器最核心的安全机制之一,由 Netscape 在 1995 年引入。所谓"同源",指的是两个 URL 的 协议(Protocol)、域名(Host)、端口(Port) 三者完全一致。

https://www.example.com:443/page 为基准:

URL是否同源原因
https://www.example.com:443/other✅ 同源协议、域名、端口均相同
http://www.example.com:443/page❌ 非同源协议不同(http vs https)
https://api.example.com:443/page❌ 非同源域名不同(子域名也算不同源)
https://www.example.com:8080/page❌ 非同源端口不同

需要注意:IE 浏览器在同源判断中不考虑端口,这是一个历史遗留的特殊行为,现代浏览器已统一将端口纳入判断。

1.2 同源策略限制了什么

同源策略主要限制以下三类行为:

  1. DOM 访问限制:无法通过 JavaScript 读取或操作不同源页面的 DOM。例如,一个 iframe 嵌入了不同源的页面,父页面无法访问 iframe 内部的 DOM 节点。

  2. 数据存储限制:无法读取不同源的 Cookie、localStorage、IndexedDB。Cookie 的限制稍宽松一些,可以通过设置 domain 属性在父子域之间共享。

  3. 网络请求限制:通过 XMLHttpRequest 或 Fetch API 发起的跨域请求,浏览器会拦截响应(注意:请求实际已发出,只是浏览器不允许 JavaScript 读取响应内容)。

1.3 不受同源策略限制的标签

并非所有资源加载都受限于同源策略,以下标签天然支持跨域加载:

  • <img src="...">:加载跨域图片
  • <link href="...">:加载跨域 CSS
  • <script src="...">:加载跨域脚本(JSONP 正是利用了这一点)
  • <video> / <audio>:加载跨域媒体资源
  • <iframe>:可以嵌入跨域页面(但无法操作其 DOM)

这些标签能跨域加载资源,但 JavaScript 仍然无法读取其具体内容(如 canvas 读取跨域图片像素会被污染)。

二、CORS(跨域资源共享)

CORS(Cross-Origin Resource Sharing)是 W3C 标准,也是目前最主流、最规范的跨域解决方案。它通过一组 HTTP 头部字段,让服务端声明哪些源可以访问自己的资源。

2.1 简单请求

满足以下全部条件的请求,浏览器会直接发送,不触发预检:

  • 方法为 GETHEADPOST 之一
  • 请求头仅包含:AcceptAccept-LanguageContent-LanguageContent-Type
  • Content-Type 的值仅为:text/plainmultipart/form-dataapplication/x-www-form-urlencoded

简单请求的处理流程:

浏览器                                    服务器
  |                                        |
  |--- GET /api/data ------------------>   |
  |    Origin: https://example.com         |
  |                                        |
  |<-- 200 OK -------------------------   |
  |    Access-Control-Allow-Origin: *      |
  |    响应体                              |

浏览器在请求头中自动加上 Origin 字段,服务器通过响应头 Access-Control-Allow-Origin 表明是否允许该源访问。如果响应头中没有这个字段,或者值与请求的 Origin 不匹配,浏览器会拦截响应。

2.2 预检请求

不满足简单请求条件的,浏览器会先发送一个 OPTIONS 方法的预检请求(Preflight Request),询问服务器是否允许实际请求。

常见触发预检的场景:

  • 使用了 PUTDELETEPATCH 等方法
  • 请求头包含自定义字段(如 AuthorizationX-Custom-Header
  • Content-Typeapplication/json

预检请求的完整流程:

浏览器                                          服务器
  |                                              |
  |--- OPTIONS /api/data ------------------>     |
  |    Origin: https://example.com               |
  |    Access-Control-Request-Method: PUT        |
  |    Access-Control-Request-Headers: Content-Type, Authorization
  |                                              |
  |<-- 204 No Content ---------------------     |
  |    Access-Control-Allow-Origin: https://example.com
  |    Access-Control-Allow-Methods: GET, PUT, POST, DELETE
  |    Access-Control-Allow-Headers: Content-Type, Authorization
  |    Access-Control-Max-Age: 86400             |
  |                                              |
  |--- PUT /api/data --------------------->     |
  |    Origin: https://example.com               |
  |    Content-Type: application/json            |
  |    Authorization: Bearer xxx                 |
  |                                              |
  |<-- 200 OK ----------------------------     |
  |    Access-Control-Allow-Origin: https://example.com
  |    响应体                                    |

2.3 CORS 相关头部字段

请求头(浏览器自动添加):

字段说明
Origin请求的源(协议 + 域名 + 端口)
Access-Control-Request-Method预检请求中声明实际请求将使用的方法
Access-Control-Request-Headers预检请求中声明实际请求将携带的自定义头

响应头(服务端配置):

字段说明
Access-Control-Allow-Origin允许的源,* 表示任意源(不能与 credentials 同时使用)
Access-Control-Allow-Methods允许的 HTTP 方法列表
Access-Control-Allow-Headers允许的请求头列表
Access-Control-Max-Age预检结果的缓存时间(秒),避免重复预检
Access-Control-Allow-Credentials是否允许携带凭据(Cookie 等)
Access-Control-Expose-Headers允许前端读取的响应头(默认只能读取基本头部)

默认情况下,跨域请求不会携带 Cookie。要携带 Cookie,需要同时满足两个条件:

前端设置:

// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;

// Fetch API
fetch('https://api.example.com/data', {
  credentials: 'include'
});

// Axios
axios.get('https://api.example.com/data', {
  withCredentials: true
});

服务端设置:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://example.com  // 不能为 *

⚠️ 关键限制:当 Access-Control-Allow-Credentialstrue 时,Access-Control-Allow-Origin 不能设置为通配符 *,必须指定具体的源。这是浏览器的安全要求。

面试追问:为什么 credentials 模式下不允许 Access-Control-Allow-Origin: *

因为 Cookie 是敏感信息。如果允许任意源携带 Cookie 访问,等于完全绕过了同源策略对 Cookie 的保护,任何恶意网站都可以利用用户的 Cookie 向目标服务器发起请求(CSRF 攻击)。限制为具体源可以确保只有被信任的源才能携带凭据。

2.5 Node.js 中配置 CORS 示例

// Express + cors 中间件
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors({
  origin: 'https://example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400
}));
// 手动实现 CORS 中间件
function corsMiddleware(req, res, next) {
  const allowedOrigin = 'https://example.com';
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader('Access-Control-Max-Age', '86400');

  if (req.method === 'OPTIONS') {
    res.sendStatus(204);
    return;
  }
  next();
}

三、JSONP

3.1 原理

JSONP(JSON with Padding)利用 <script> 标签不受同源策略限制的特性实现跨域数据获取。本质上是:前端定义一个回调函数,通过 <script> 标签向服务端请求一段 JavaScript 代码,服务端将数据包裹在回调函数调用中返回。

3.2 实现

前端:

function handleResponse(data) {
  console.log('收到数据:', data);
}

const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleResponse';
document.body.appendChild(script);

// 请求返回的内容实际上是一段 JS:
// handleResponse({"name": "nanyi", "age": 18})

服务端(Node.js):

app.get('/data', (req, res) => {
  const callbackName = req.query.callback;
  const data = JSON.stringify({ name: 'nanyi', age: 18 });
  res.type('application/javascript');
  res.send(`${callbackName}(${data})`);
});

封装一个通用的 JSONP 函数:

function jsonp(url, callbackName = 'callback') {
  return new Promise((resolve, reject) => {
    const functionName = `jsonp_${Date.now()}_${Math.random().toString(36).slice(2)}`;

    window[functionName] = (data) => {
      resolve(data);
      document.body.removeChild(script);
      delete window[functionName];
    };

    const script = document.createElement('script');
    script.src = `${url}${url.includes('?') ? '&' : '?'}${callbackName}=${functionName}`;
    script.onerror = () => {
      reject(new Error('JSONP request failed'));
      document.body.removeChild(script);
      delete window[functionName];
    };
    document.body.appendChild(script);
  });
}

// 使用
jsonp('https://api.example.com/data')
  .then(data => console.log(data));

3.3 JSONP 的局限性

  • 只支持 GET 请求:因为 <script> 标签只能发 GET
  • 安全风险:本质上是执行任意远程脚本,如果服务端被攻击,返回的恶意代码会直接执行
  • 无法感知 HTTP 状态码<script> 标签的 onerror 无法获取具体错误信息
  • 需要服务端配合:服务端必须按约定格式返回

四、代理方案

同源策略是浏览器的限制,服务器之间的通信不存在跨域问题。因此可以通过代理服务器转发请求来规避跨域。

4.1 Nginx 反向代理

这是生产环境最常见的跨域方案。Nginx 作为反向代理服务器,将前端的跨域请求转发到目标服务器。

server {
    listen 80;
    server_name www.example.com;

    # 前端静态资源
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # API 请求代理到后端服务
    location /api/ {
        proxy_pass http://backend-server:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

前端请求 https://www.example.com/api/users 时,Nginx 会将请求转发到 http://backend-server:3000/users,对浏览器而言始终是同源请求。

4.2 Node 中间层代理

在开发环境中,前端脚手架通常内置了代理功能。

Webpack Dev Server:

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

Vite:

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
});

手动实现(http-proxy-middleware):

const { createProxyMiddleware } = require('http-proxy-middleware');

app.use('/api', createProxyMiddleware({
  target: 'http://localhost:3000',
  changeOrigin: true,
  pathRewrite: { '^/api': '' }
}));

changeOrigin: true 的作用是将请求头中的 Host 修改为目标服务器的地址,某些后端服务会校验 Host 字段,不设置可能导致请求被拒绝。

4.3 Nginx 代理 vs Node 代理的选择

维度Nginx 反向代理Node 中间层代理
使用场景生产环境开发环境
性能高,C 语言编写,事件驱动相对较低
配置灵活性通过配置文件,重启生效代码级别控制,热更新
额外功能负载均衡、SSL 终止、缓存可添加自定义中间件逻辑

五、WebSocket 跨域

WebSocket 协议本身不受同源策略限制。WebSocket 建立连接时使用 HTTP 进行握手,但握手成功后会升级为 WebSocket 协议,后续通信不再走 HTTP,因此浏览器不会对其施加同源限制。

// 前端
const socket = new WebSocket('wss://api.example.com/ws');

socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'hello' }));
};

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('收到消息:', data);
};
// 服务端(使用 ws 库)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws, req) => {
  // 可以通过 req.headers.origin 校验来源
  const origin = req.headers.origin;
  if (origin !== 'https://example.com') {
    ws.close();
    return;
  }

  ws.on('message', (message) => {
    ws.send(JSON.stringify({ echo: message }));
  });
});

虽然 WebSocket 不受同源策略限制,但服务端仍应校验 Origin 头部以防止未授权的连接。

六、postMessage 跨域

window.postMessage 是 HTML5 引入的 API,用于不同窗口(包括不同源的 iframe、window.open 打开的窗口)之间的安全通信。

6.1 基本用法

父页面(https://parent.com):

// 向 iframe 发送消息
const iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage(
  { type: 'greeting', payload: 'hello' },
  'https://child.com' // 目标源,务必指定具体值
);

// 接收 iframe 的回复
window.addEventListener('message', (event) => {
  // 始终校验来源
  if (event.origin !== 'https://child.com') return;
  console.log('收到回复:', event.data);
});

子页面(https://child.com,嵌在 iframe 中):

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://parent.com') return;
  console.log('收到消息:', event.data);

  // 回复父页面
  event.source.postMessage(
    { type: 'reply', payload: 'world' },
    event.origin
  );
});

6.2 安全注意事项

  • 发送时指定目标源postMessage 的第二个参数应指定精确的目标 origin,而非 *,防止消息被其他窗口截获
  • 接收时校验来源:必须检查 event.origin,否则任何页面都能向你发送消息
  • 不要用 eval 处理消息内容:消息数据应当作不可信输入处理

七、document.domain(已废弃)

document.domain 曾经用于解决同一主域下不同子域之间的跨域问题。例如 a.example.comb.example.com 可以将各自的 document.domain 设为 example.com,从而实现 DOM 互访和 Cookie 共享。

// a.example.com 和 b.example.com 都执行:
document.domain = 'example.com';

⚠️ 重要提醒: Chrome 从 101 版本开始已经禁用了 document.domain 的设置,其他浏览器也在跟进。W3C 规范已将其标记为废弃特性。原因是这种方式会削弱同源策略的保护,任何一个子域被攻击都会危及整个主域。

现代替代方案是使用 postMessage 进行跨子域通信。

八、方案对比

方案适用场景请求方法限制需要服务端配合安全性推荐程度
CORS通用 API 请求无限制需要设置响应头⭐⭐⭐⭐⭐
Nginx 反向代理生产环境部署无限制需要 Nginx 配置⭐⭐⭐⭐⭐
Node 代理开发环境无限制脚手架配置⭐⭐⭐⭐
JSONP兼容老浏览器仅 GET需要返回回调格式⭐⭐
WebSocket实时双向通信WebSocket 协议需要 WS 服务⭐⭐⭐⭐
postMessage窗口间通信无(非 HTTP)不需要⭐⭐⭐⭐
document.domain同主域子域通信不需要❌ 已废弃

九、面试高频问题

Q:跨域请求到底有没有发出去? A:发出去了。同源策略限制的是浏览器对响应的读取,而非请求的发送。简单请求会直接发出;非简单请求会先发预检,预检通过后才发实际请求。

Q:CORS 预检请求可以缓存吗? A:可以。通过 Access-Control-Max-Age 响应头设置缓存时间(单位秒),在缓存有效期内相同请求不会重复发送预检。Chrome 最大值为 7200 秒(2 小时),Firefox 最大值为 86400 秒(24 小时)。

Q:为什么表单提交不受同源策略限制? A:同源策略主要防止的是 JavaScript 读取跨域响应。表单提交后页面会导航到新的 URL(或在新窗口打开),JavaScript 无法读取目标页面的内容,因此不违反同源策略的保护目标。这也是为什么 CSRF 攻击通常利用表单提交。

Q:Access-Control-Allow-Origin 能设置多个值吗? A:不能直接设置多个值。但服务端可以根据请求的 Origin 头动态返回对应的值:

const allowedOrigins = ['https://a.com', 'https://b.com'];

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin'); // 重要:告知缓存服务器按 Origin 区分
  }
  next();
}

Q:如何解决跨域携带 Cookie 被 SameSite 策略阻止的问题? A:Chrome 80+ 将 Cookie 的 SameSite 属性默认值从 None 改为 Lax,导致跨站请求不再自动携带 Cookie。解决方法是在设置 Cookie 时显式声明 SameSite=None; Secure(必须同时使用 HTTPS)。

Set-Cookie: session_id=abc123; SameSite=None; Secure; HttpOnly

Q:CORS 和 CSP 有什么区别? A:两者都是浏览器安全机制,但保护方向不同。CORS 控制的是谁能读取我的资源(服务端声明允许哪些外部源访问);CSP(Content Security Policy)控制的是我的页面能加载哪些资源(防止 XSS 等注入攻击)。