同源策略,跨域问题与解决方案
问题
什么是跨域?为什么会有跨域限制?有哪些跨域解决方案?
答案
跨域是浏览器的同源策略导致的安全限制。当协议、域名、端口任一不同时,就会产生跨域问题。
同源策略
什么是同源?
同源是指协议、域名、端口都相同:
https://example.com:443/path
│ │ │
协议 域名 端口
| URL | 与 https://example.com 是否同源 | 原因 |
|---|---|---|
https://example.com/page | ✅ 同源 | 完全相同 |
http://example.com | ❌ 跨域 | 协议不同 |
https://api.example.com | ❌ 跨域 | 子域名不同 |
https://example.com:8080 | ❌ 跨域 | 端口不同 |
https://other.com | ❌ 跨域 | 域名不同 |
同源策略限制什么?
| 行为 | 是否限制 | 说明 |
|---|---|---|
| Cookie、LocalStorage | ✅ 限制 | 无法读取跨域的存储 |
| DOM 访问 | ✅ 限制 | 无法操作跨域 iframe 的 DOM |
| AJAX 请求 | ✅ 限制 | 无法发送跨域请求(或无法读取响应) |
<script> 加载 | ❌ 不限制 | 可以加载跨域脚本 |
<img> 加载 | ❌ 不限制 | 可以加载跨域图片 |
<link> 加载 | ❌ 不限制 | 可以加载跨域样式 |
同源策略是浏览器的安全机制,防止恶意网站:
- 窃取其他网站的用户数据
- 冒充用户发送请求(CSRF)
- 读取用户的敏感信息
CORS(跨域资源共享)
CORS(Cross-Origin Resource Sharing)是现代浏览器的标准跨域解决方案。
简单请求
满足以下条件的请求是简单请求,不会触发预检:
// 简单请求条件
const isSimpleRequest =
// 1. 方法是 GET、HEAD、POST 之一
['GET', 'HEAD', 'POST'].includes(method) &&
// 2. 只有安全的请求头
headers只包含Accept、AcceptLanguage、ContentLanguage、ContentType &&
// 3. Content-Type 只能是以下三种
['text/plain', 'multipart/form-data', 'application/x-www-form-urlencoded']
.includes(contentType);
预检请求(Preflight)
非简单请求会先发送 OPTIONS 预检请求:
// 会触发预检的请求
fetch('https://api.example.com/data', {
method: 'PUT', // 非简单方法
headers: {
'Content-Type': 'application/json', // 非简单 Content-Type
'Authorization': 'Bearer token', // 自定义头
},
body: JSON.stringify({ name: 'test' }),
});
CORS 响应头
| 响应头 | 说明 | 示例 |
|---|---|---|
Access-Control-Allow-Origin | 允许的源 | * 或具体域名 |
Access-Control-Allow-Methods | 允许的方法 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 允许的请求头 | Content-Type, Authorization |
Access-Control-Allow-Credentials | 是否允许携带凭证 | true |
Access-Control-Max-Age | 预检结果缓存时间(秒) | 86400 |
Access-Control-Expose-Headers | 暴露给 JS 的响应头 | X-Custom-Header |
服务端配置示例
// Node.js + Express
import cors from 'cors';
app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));
// 或手动设置
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://example.com');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '86400');
return res.sendStatus(204);
}
next();
});
// Nginx 配置
/*
location /api {
add_header 'Access-Control-Allow-Origin' 'https://example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Credentials' 'true';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
*/
携带凭证(Cookies)
// 前端:需要设置 credentials
fetch('https://api.example.com/user', {
credentials: 'include', // 携带 cookies
});
// 或 axios
axios.defaults.withCredentials = true;
当 credentials: 'include' 时:
Access-Control-Allow-Origin不能是*,必须是具体域名Access-Control-Allow-Credentials必须是true
JSONP
JSONP 利用 <script> 标签不受同源限制的特性,只支持 GET 请求:
// JSONP 实现
function jsonp<T>(url: string, callbackName: string): Promise<T> {
return new Promise((resolve, reject) => {
// 创建全局回调函数
const callback = `jsonp_${Date.now()}`;
(window as any)[callback] = (data: T) => {
resolve(data);
document.head.removeChild(script);
delete (window as any)[callback];
};
// 创建 script 标签
const script = document.createElement('script');
script.src = `${url}?callback=${callback}`;
script.onerror = () => reject(new Error('JSONP request failed'));
document.head.appendChild(script);
});
}
// 使用
const data = await jsonp<{ name: string }>(
'https://api.example.com/user',
'callback'
);
// 服务端返回
// https://api.example.com/user?callback=jsonp_123456
// 响应:jsonp_123456({ "name": "Alice" })
- 只支持 GET 请求
- 安全性差:容易被 XSS 攻击
- 难以处理错误:无法获取 HTTP 状态码
- 已过时:现代应用推荐使用 CORS
代理服务器
通过同源的代理服务器转发请求,绕过浏览器限制:
开发环境代理
// Vite 配置
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
// Webpack devServer
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
};
生产环境代理
# Nginx 反向代理
server {
listen 80;
server_name example.com;
location /api {
proxy_pass https://api.other.com;
proxy_set_header Host api.other.com;
proxy_set_header X-Real-IP $remote_addr;
}
}
postMessage
postMessage 用于跨窗口通信(iframe、window.open):
// 父页面发送消息
const iframe = document.getElementById('myIframe') as HTMLIFrameElement;
iframe.contentWindow?.postMessage(
{ type: 'greeting', data: 'Hello' },
'https://child.example.com' // 目标源,* 表示任意
);
// 父页面接收消息
window.addEventListener('message', (event) => {
// 验证来源!
if (event.origin !== 'https://child.example.com') return;
console.log('收到消息:', event.data);
});
// ---
// 子页面(iframe)接收消息
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.example.com') return;
console.log('子页面收到:', event.data);
// 回复消息
event.source?.postMessage(
{ type: 'reply', data: 'Hi back!' },
{ targetOrigin: event.origin }
);
});
- 始终验证
event.origin:防止接收恶意消息 - 指定目标源:避免使用
*,除非确实需要广播 - 验证消息格式:检查
event.data的结构
document.domain
同一主域下的子域可以通过设置 document.domain 共享:
// https://a.example.com
document.domain = 'example.com';
// https://b.example.com
document.domain = 'example.com';
// 此时两个页面可以互相访问
document.domain 已被标记为废弃,现代浏览器正在移除支持。请使用 postMessage 替代。
跨域方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CORS | 所有跨域请求 | 标准、安全、功能完整 | 需要服务端配置 |
| 代理 | 开发/生产环境 | 前端无感知 | 需要部署代理服务 |
| JSONP | 兼容旧浏览器 | 兼容性好 | 只支持 GET、不安全 |
| postMessage | 窗口通信 | 安全、灵活 | 仅限窗口间通信 |
| WebSocket | 实时通信 | 全双工、无跨域限制 | 需要 WebSocket 服务 |
常见面试问题
Q1: 什么是跨域?为什么会产生跨域?
答案:
跨域是由浏览器的同源策略导致的。当请求的 URL 与当前页面的协议、域名、端口任一不同时,就会产生跨域。
同源策略的目的是保护用户安全:
- 防止恶意网站读取其他网站的数据
- 防止 CSRF 攻击
- 保护用户隐私
// 当前页面:https://example.com
fetch('https://api.other.com/data'); // 跨域!
fetch('http://example.com/data'); // 跨域!协议不同
fetch('https://example.com:8080/data'); // 跨域!端口不同
Q2: CORS 的简单请求和预检请求有什么区别?
答案:
| 特性 | 简单请求 | 预检请求 |
|---|---|---|
| 请求数量 | 1 次 | 2 次(OPTIONS + 实际请求) |
| 触发条件 | GET/HEAD/POST + 简单头 | 其他方法或自定义头 |
| Content-Type | 三种简单类型 | application/json 等 |
简单请求条件:
// 1. 方法是 GET、HEAD、POST
// 2. 头只有:Accept, Accept-Language, Content-Language, Content-Type
// 3. Content-Type 是:text/plain, multipart/form-data, application/x-www-form-urlencoded
Q3: 跨域请求能发出去吗?
答案:
能发出去! 跨域请求的限制是浏览器不让你读取响应,而不是阻止请求发送。
这也是为什么 CSRF 攻击能成功——请求确实发送了,服务端也处理了。
Q4: 如何解决跨域携带 Cookie 的问题?
答案:
// 前端
fetch(url, { credentials: 'include' });
// 服务端必须设置
res.header('Access-Control-Allow-Origin', 'https://example.com'); // 不能是 *
res.header('Access-Control-Allow-Credentials', 'true');
注意:Allow-Origin 不能是 *,必须是具体的域名。
Q5: JSONP 的原理是什么?有什么缺点?
答案:
原理:利用 <script> 标签不受同源策略限制的特性,动态创建 script 标签请求数据。
// 1. 创建回调函数
window.callback = (data) => console.log(data);
// 2. 创建 script 标签
const script = document.createElement('script');
script.src = 'https://api.com/data?callback=callback';
// 服务端返回:callback({ "name": "Alice" })
缺点:
- 只支持 GET 请求
- 安全性差,容易被 XSS 攻击
- 无法获取 HTTP 状态码
- 已过时,推荐 CORS
Q6: 什么是简单请求和预检请求?触发预检请求的条件?
答案:
浏览器将 CORS 请求分为简单请求和预检请求两类,区别在于是否需要先发送一个 OPTIONS 请求进行"预检"。
简单请求必须同时满足以下所有条件:
| 条件 | 要求 |
|---|---|
| 请求方法 | GET、HEAD、POST 之一 |
| 请求头 | 仅包含 Accept、Accept-Language、Content-Language、Content-Type |
| Content-Type | 仅限 text/plain、multipart/form-data、application/x-www-form-urlencoded |
只要不满足上述任一条件,浏览器就会自动发送预检请求(OPTIONS)。
// ✅ 简单请求 - 不会触发预检
fetch('https://api.example.com/data', {
method: 'GET',
});
fetch('https://api.example.com/form', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=test',
});
// ❌ 以下都会触发预检请求
// 原因 1:使用了非简单方法 PUT
fetch('https://api.example.com/data', {
method: 'PUT',
body: JSON.stringify({ name: 'test' }),
});
// 原因 2:Content-Type 是 application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'test' }),
});
// 原因 3:包含自定义请求头
fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer token123' },
});
预检请求的完整流程:
通过 Access-Control-Max-Age 可以缓存预检结果,在缓存有效期内不会重复发送 OPTIONS 请求,从而减少一次网络往返。常见设置为 86400(24 小时)。
Q7: CORS 的 credentials 模式和 Cookie 的关系
答案:
默认情况下,跨域请求不会携带 Cookie。要让跨域请求携带 Cookie,需要前后端同时配置。
三种 credentials 模式:
| 模式 | 说明 | 是否携带 Cookie |
|---|---|---|
omit | 从不携带凭证 | ❌ |
same-origin | 仅同源携带(默认值) | 同源 ✅ / 跨域 ❌ |
include | 始终携带凭证 | ✅ |
// 前端设置
// 方式 1:fetch API
fetch('https://api.example.com/user', {
credentials: 'include', // 携带跨域 Cookie
});
// 方式 2:XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', 'https://api.example.com/user');
xhr.send();
// 方式 3:axios
import axios from 'axios';
axios.get('https://api.example.com/user', {
withCredentials: true,
});
// 或全局配置
axios.defaults.withCredentials = true;
// 服务端设置(Express 示例)
import cors from 'cors';
app.use(cors({
origin: 'https://app.example.com', // 不能是 *
credentials: true, // 必须为 true
}));
当 credentials: 'include' 时,服务端有以下硬性限制:
Access-Control-Allow-Origin不能是*,必须指定具体域名Access-Control-Allow-Headers不能是*Access-Control-Allow-Methods不能是*Access-Control-Allow-Credentials必须为true
违反以上任一条件,浏览器都会拦截响应。
多域名场景的动态 Origin 处理:
// 当需要支持多个域名时,根据请求的 Origin 动态设置
import { Request, Response, NextFunction } from 'express';
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
'https://m.example.com',
];
function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
if (req.method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '86400');
res.sendStatus(204);
return;
}
next();
}
Q8: 开发环境和生产环境跨域方案的区别
答案:
开发环境和生产环境的跨域解决方案有本质区别:
| 对比 | 开发环境 | 生产环境 |
|---|---|---|
| 核心方案 | 开发服务器代理 | Nginx 反向代理 / CORS 配置 |
| 原理 | 本地代理绕过浏览器限制 | 服务端直接处理跨域 |
| 配置位置 | Vite/Webpack 配置文件 | Nginx/服务端代码 |
| 请求路径 | 浏览器 → 本地代理 → API 服务器 | 浏览器 → Nginx → API 服务器 |
开发环境方案 - 开发服务器代理:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// 字符串简写
'/foo': 'http://localhost:4567',
// 完整配置
'/api': {
target: 'https://api.example.com',
changeOrigin: true, // 修改请求头中的 Host
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径
secure: false, // 接受无效证书(开发环境)
},
// WebSocket 代理
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
});
import { Configuration } from 'webpack';
const config: Configuration = {
devServer: {
proxy: [
{
context: ['/api'],
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
],
},
};
export default config;
开发服务器代理的本质是:浏览器请求的是本地 localhost(同源),然后由 Node.js 服务端代为转发请求到目标 API 服务器。因为服务端之间的 HTTP 请求不受同源策略限制,所以能绕过跨域。
生产环境方案 1 - Nginx 反向代理:
server {
listen 80;
server_name www.example.com;
# 前端静态资源
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# API 代理 - 浏览器请求 /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;
}
}
生产环境方案 2 - 服务端配置 CORS:
import express from 'express';
import cors from 'cors';
const app = express();
// 生产环境 CORS 配置,严格限制 Origin
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = [
'https://www.example.com',
'https://app.example.com',
];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));
生产环境方案 3 - 同域部署(彻底避免跨域):
- 不要在生产环境使用
Access-Control-Allow-Origin: *:这会允许任何网站访问你的 API,存在安全风险 - 开发环境的代理配置不会影响生产环境:Vite/Webpack 代理只在本地开发服务器中生效
- Nginx 反向代理和 CORS 二选一即可:如果用了 Nginx 代理,前后端同域,就不需要 CORS 配置