同源策略(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 头 |