跨域与同源策略
一、同源策略
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 同源策略限制了什么
同源策略主要限制以下三类行为:
-
DOM 访问限制:无法通过 JavaScript 读取或操作不同源页面的 DOM。例如,一个 iframe 嵌入了不同源的页面,父页面无法访问 iframe 内部的 DOM 节点。
-
数据存储限制:无法读取不同源的 Cookie、localStorage、IndexedDB。Cookie 的限制稍宽松一些,可以通过设置
domain属性在父子域之间共享。 -
网络请求限制:通过 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 简单请求
满足以下全部条件的请求,浏览器会直接发送,不触发预检:
- 方法为
GET、HEAD、POST之一 - 请求头仅包含:
Accept、Accept-Language、Content-Language、Content-Type Content-Type的值仅为:text/plain、multipart/form-data、application/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),询问服务器是否允许实际请求。
常见触发预检的场景:
- 使用了
PUT、DELETE、PATCH等方法 - 请求头包含自定义字段(如
Authorization、X-Custom-Header) Content-Type为application/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 | 允许前端读取的响应头(默认只能读取基本头部) |
2.4 withCredentials 与 Cookie
默认情况下,跨域请求不会携带 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-Credentials 为 true 时,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.com 和 b.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 等注入攻击)。