计算机“十万个为什么”之跨域
本文是计算机“十万个为什么”系列的第五篇,主要是介绍跨域的相关知识。
作者:无限大
推荐阅读时间:10 分钟
一、引言:为什么会有跨域这个“拦路虎”?
想象你正在参观一座戒备森严的城堡 🏰
🚪 城堡大门 = 浏览器安全机制
📜 访客通行证 = 同源策略
🔄 没有通行证却想进入其他城堡的访客 = 跨域请求
在 Web 世界中,跨域就像城堡之间的访问限制,是浏览器为保护用户数据安全而设置的重要防线。但为什么需要这样的限制?当我们访问不同网站时到底发生了什么?这篇文章将带你深入探索跨域的奥秘,从基础概念到高级解决方案,全面理解这个 Web 开发中不可避免的技术挑战。
二、跨域的本质:浏览器的“安全守门人”
🧐 什么是同源策略?
同源策略(Same-Origin Policy) 是浏览器实施的核心安全策略,它要求网页只能请求与其自身协议、域名、端口完全相同的资源。这就像现实生活中,你家的钥匙只能打开你家的门,不能打开邻居家的门一样,是一种最基本的安全边界。
🔍 同源判断标准(三要素)
要素 | 说明 | 示例 |
---|---|---|
协议 | 通信协议必须相同 | http 与 https 不同 |
域名 | 主域名和子域名都必须相同 | www.example.com 与 api.example.com 不同 |
端口 | 网络端口号必须相同 | 80 与 8080 不同 |
注意:IE 浏览器在判断同源时存在例外,它不检查端口,并且允许主域名相同的不同子域之间通信。这是历史遗留问题,现代浏览器已修复此行为。
🚫 典型跨域场景示例
当前页面 URL | 请求资源 URL | 是否跨域 | 原因 |
---|---|---|---|
http://www.example.com | https://www.example.com/api | ✅ 是 | 协议不同 (http vs https) |
http://www.example.com | http://www.baidu.com | ✅ 是 | 域名不同 |
http://www.example.com:80 | http://www.example.com:8080 | ✅ 是 | 端口不同 |
http://www.example.com | http://api.example.com | ✅ 是 | 子域名不同 |
http://www.example.com | http://www.example.com/path | ❌ 否 | 完全同源 |
💡 为什么需要同源策略?
同源策略看似“限制重重”,实则是保护用户安全的重要屏障。它通过严格的边界控制,构建了 Web 安全的第一道防线。没有它,互联网将变成危机四伏的“狂野西部”。
🔍 没有同源策略的安全灾难
想象一个没有门禁系统的办公楼——任何人都可以自由进出任何办公室,翻阅文件柜,甚至冒充员工签署文件。同源策略正是 Web 世界的门禁系统,防止以下三类致命攻击:
1. Cookie 劫持攻击:身份盗窃的温床
攻击原理:Cookie 通常存储用户登录凭证。没有同源限制,恶意网站可通过 document.cookie
直接读取其他网站的 Cookie,获取你的银行账户、邮箱、社交平台等登录状态。
真实案例:2018 年 Facebook 剑桥分析事件中,第三方应用通过获取用户 Cookie 数据,在未经许可情况下访问了 8700 万用户的个人信息。
防护机制:同源策略禁止不同源页面访问 Cookie,配合 HttpOnly
属性可进一步防止 JavaScript 读取敏感 Cookie。
2. DOM 篡改攻击:视觉欺诈的陷阱
攻击原理:恶意网站可通过 JavaScript 操作其他网站的 DOM 结构,例如在银行页面上覆盖虚假的登录表单,或修改电商网站的支付金额。
典型场景:当你同时打开 yourbank.com
和 fakebank.com
时,后者可修改前者页面内容,将转账金额从 100 元改为 10000 元,而你完全无法察觉。
防护机制:同源策略禁止跨域 DOM 访问,确保每个网站的页面内容只能被自身 JavaScript 操控。
3. 跨站请求伪造(CSRF):身份冒用的武器
攻击原理:恶意网站可伪造请求,利用你已登录的身份向其他网站发送操作指令。例如,当你登录网银后访问恶意网站,它可自动发起转账请求。
技术实现:
<!-- 恶意网站隐藏表单 -->
<form action="https://yourbank.com/transfer" method="POST" id="stealForm"><input type="hidden" name="toAccount" value="attackerAccount" /><input type="hidden" name="amount" value="10000" />
</form>
<script>// 自动提交表单document.getElementById("stealForm").submit();
</script>
防护机制:同源策略限制跨域请求,结合 CSRF Token、Referer 验证等机制可有效防范。
🌰 生动案例:一次未遂的银行抢劫
假设你同时打开了两个标签页:
https://yourbank.com
(已登录网银)https://malicious.com
(恶意网站)
没有同源策略时:
- 恶意网站读取你银行页面的 Cookie,获取登录状态
- 修改银行页面 DOM,添加隐藏转账表单
- 自动提交表单,将你的资金转移到攻击者账户
同源策略如何防护:
✅ 阻止读取银行 Cookie
✅ 禁止修改银行页面 DOM
✅ 限制跨域请求发送
这就是为什么浏览器会严格执行同源策略——它不是技术限制,而是保护你数字财产的安全卫士。
三、跨域的表现:浏览器如何“拦截”请求?
很多开发者第一次遇到跨域问题时都会感到困惑:明明网络请求成功了,服务器也返回了数据,为什么前端就是拿不到?要理解这个问题,我们需要深入了解浏览器拦截跨域请求的完整流程和技术细节。
- 网络面板:显示真实的请求和响应状态(如 200 OK),因为这是服务器实际返回的状态
- 控制台:显示 CORS 错误,因为浏览器拦截了响应,前端无法访问数据
🔍 跨域错误的典型表现
当跨域请求被浏览器拦截时,控制台会出现类似以下的错误信息(不同浏览器措辞略有差异):
常见错误类型及示例
- 缺少 CORS 头部错误(最常见):
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.
- 凭据不允许错误:
Access to fetch at 'http://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
- 方法不允许错误:
Access to fetch at 'http://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.
🕵️♂️ 关键真相:请求已发送,响应被拦截
重要理解:跨域请求实际已发送到服务器,服务器也已处理并返回响应,但浏览器在将响应交给前端 JavaScript 之前进行了拦截检查。这个过程包含三个关键步骤:
浏览器拦截流程示意图
浏览器拦截的三步流程
-
请求发送阶段:
- 浏览器允许请求发送到目标服务器
- 自动添加
Origin
请求头标识来源 - 对于非简单请求,先发送预检请求(OPTIONS)
-
服务器响应阶段:
- 服务器处理请求并返回响应
- 若服务器未正确配置 CORS 头部,响应中会缺少必要的允许信息
- 即使服务器返回 200 状态码,浏览器仍可能拦截响应
-
浏览器检查阶段:
- 浏览器检查响应中的 CORS 头部
- 若检查不通过,丢弃响应数据并抛出控制台错误
- 若检查通过,将响应数据交给前端 JavaScript
这就是为什么你在网络面板(Network)中能看到 200 状态码的响应,却在控制台看到 CORS 错误的原因。浏览器充当了“安全门卫”的角色,即使服务器已提供数据,也会基于安全策略决定是否将数据交给前端。
四、跨域解决方案全景:从基础到高级
面对跨域问题,开发者们探索出了多种解决方案。选择哪种方案取决于你的具体场景:
是开发环境还是生产环境?
是简单的 GET 请求还是复杂的交互?
是否有权限修改服务器配置?
🅰️ 方案一:CORS(跨域资源共享)—— 官方标准方案
CORS(Cross-Origin Resource Sharing) 通过服务器设置 HTTP 响应头来告诉浏览器允许跨域请求,是 W3C 推荐的标准解决方案。
🔧 基本原理
- 浏览器发送请求时自动添加
Origin
头,表明请求来源 - 服务器返回
Access-Control-Allow-Origin
等响应头,表明是否允许该来源访问 3.浏览器检查响应头,决定是否将数据交给前端
📝 核心响应头配置
CORS 通过以下关键响应头控制跨域访问权限,每个头部都有特定的用途和安全考量:
-
Access-Control-Allow-Origin
- 允许值:具体的源 URL(如
https://example.com
)或通配符*
- 作用:指定允许访问资源的外部域
- 安全约束:生产环境中应明确指定源,避免使用
*
通配符;当请求需要携带凭据(如 Cookie)时,不能使用*
- 示例:
Access-Control-Allow-Origin: https://your-frontend.com
- 允许值:具体的源 URL(如
-
Access-Control-Allow-Methods
- 允许值:逗号分隔的 HTTP 方法列表(如
GET, POST, PUT, DELETE
) - 作用:指定允许的 HTTP 请求方法
- 安全约束:应仅开放必要的方法,遵循最小权限原则
- 示例:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
- 允许值:逗号分隔的 HTTP 方法列表(如
-
Access-Control-Allow-Headers
- 允许值:逗号分隔的请求头列表(如
Content-Type, Authorization
) - 作用:指定允许的自定义请求头
- 注意事项:对于非简单请求头(如 Authorization),必须显式声明
- 示例:
Access-Control-Allow-Headers: Content-Type, Authorization
- 允许值:逗号分隔的请求头列表(如
-
Access-Control-Allow-Credentials
- 允许值:布尔值
true
(仅当允许凭据时) - 作用:指示是否允许跨域请求携带凭据(如 Cookie、HTTP 认证信息)
- 安全考量:启用此选项会增加安全风险,需确保源验证严格
- 示例:
Access-Control-Allow-Credentials: true
- 允许值:布尔值
-
Access-Control-Max-Age
- 允许值:正整数(单位:秒)
- 作用:指定预检请求(OPTIONS)结果的缓存时间
- 优化建议:合理设置缓存时间(如 86400 秒=24 小时)可减少预检请求次数
- 示例:
Access-Control-Max-Age: 86400
这些响应头需要配合使用,共同构成完整的 CORS 安全策略。服务器必须正确配置这些头部才能使跨域请求正常工作。
💻 CORS 实现代码示例
Node.js/Express 实现:
const express = require("express");
const app = express();// 全局CORS中间件
app.use((req, res, next) => {// 允许指定源访问res.setHeader("Access-Control-Allow-Origin", "https://your-frontend.com");// 允许的方法res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");// 允许的请求头res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");// 允许携带Cookieres.setHeader("Access-Control-Allow-Credentials", "true");// 处理预检请求if (req.method === "OPTIONS") {res.statusCode = 204; // 预检请求不需要响应体return res.end();}next();
});// API路由
app.get("/data", (req, res) => {res.json({ message: "跨域请求成功!" });
});app.listen(3000, () => {console.log("服务器运行在端口3000");
});
Nginx 配置:
server {listen ;server_name api.example.com;location / {# 允许跨域add_header Access-Control-$allow_origin https://example.com;add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE;add_header Access-Control-Allow-Headers Content-Type,Authorization;add_header Access-Control-Allow-Credentials true;# 预检请求直接返回204if ($request_method = 'OPTIONS') {return ;}proxy_pass http://localhost:3000;}
}
⚠️ CORS 安全最佳实践
- 避免使用
*
通配符: 在生产环境中应明确指定允许访问的源 - 限制允许的方法:只开放必要 HTTP 方法
- 谨慎启用 Credentials:允许 Cookie 跨域传输会增加安全风险
- 合理设置 Max-Age:减少预检请求次数提升性能
🅱️ 方案二:JSONP —— 古老但仍在使用的技巧
JSONP (JSON with Padding) 是一种利用 <script>
标签不受同源策略限制特性的跨域方案,虽然古老但在一些兼容性要求高的场景仍有应用。
🔧 工作原理
- 前端创建
<script>
标签并指定服务器 URL,附带回调函数名 - 服务器返回 JavaScript 代码,格式为
回调函数名(数据)
- 浏览器执行返回的 JavaScript,调用回调函数处理数据
💻 JSONP 实现代码示例
前端实现:
// 创建回调函数
function handleResponse(data) {console.log("JSONP 返回数据:", data);
}// 动态创建 script 标签
function fetchDataWithJSONP() {const script = document.createElement("script");// 传递回调函数名给服务器script.src = "http://api.example.com/data?callback=handleResponse";document.body.appendChild(script);// 使用后移除 script 标签script.onload = () => {document.body.removeChild(script);};
}// 调用函数发起请求
fetchDataWithJSONP();
服务器实现(Node.js):
const http = require('http');
const url = require('url');const server = http.createServer((req,const query = url.parse(req.url,const callback = query.callback;const data = JSON.stringify({ message: 'JSONP请求成功' });// 返回JavaScript代码,调用回调函数res.writeHead(res.end(`${callback}(${data})`);
});server.listen(3000);
⚠️ JSONP 的局限性
1.仅支持 GET 请求:无法发送 POST 等复杂请求
2.安全风险:可能遭受 XSS 攻击
3.错误处理困难:缺乏标准的错误处理机制
4.无法设置请求头:难以实现认证等功能
JSONP 已逐渐被 CORS 取代,但在需要兼容极低版本浏览器的场景仍有使用价值。
🅲️ 方案三: 代理服务器 —— 前端无感方案
代理服务器通过在同域服务器端转发请求来绕过浏览器同源限制,是开发环境中最常用方案之一。
🔧 工作原理
代理服务器充当中间人,将跨域请求转发到目标服务器,前端只与同域代理服务器通信,浏览器不会触发跨域限制。
-
前端将请求发送到同域代理服务器
-
代理服务器转发请求到目标服务器
-
目标服务器返回响应给代理服务器
-
代理服务器将响应返回给前端
由于前端只与同域代理服务器通信浏览器不会触发跨域限制
💻 开发环境代理配置
Vite 配置:
// vite.config.js
export default {server: {proxy: {"/api": {target: "http://api.example.com", //目标服务器changeOrigin: true, // 更改请求源rewrite: (path) => path.replace(/^\/api/, ""), // 可选重写路径},},},
};
Webpack 配置:
// webpack.config.js
module.exports = {devServer: {proxy:'/api': {target: 'http://api.example.com',changeOrigin: true,pathRewrite: {'^/api': ''}}}}
🚀 生产环境代理(Nginx)
server {listen ;server_name example.com;# 静态资源location / {root /usr/share/nginx/html;index index.html;}# API代理location /api/ {proxy_pass http://api.example.com/; #转发到目标服务器proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;}
}
⚠️ 代理服务器的局限性
- 开发环境依赖:需要配置代理服务器,生产环境可能不适用
- 性能开销:增加了请求转发的延迟
- 安全风险:代理服务器可能成为攻击目标,需加强安全配置
- 跨域限制:仍需服务器端配合,无法完全解决跨域问题
🅳️ 方案四:WebSocket ——实时通信跨域方案
WebSocket 协议是 HTML5 引入的全双工通信协议它不受同源策略限制,特别适合实时通信场景。
🔧 工作原理
WebSocket 通过一次握手建立持久连接之后的通信不再受同源策略限制。
💻 WebSocket 实现代码
前端实现:
// 创建 WebSocket 连接
const socket = new WebSocket('ws://api.example.com/chat');// 连接建立时触发
socket.addEventListener('open', (event) => {console.log('WebSocket 连接已建立');socket.send('Hello Server!'); // 发送消息
});// 接收服务器消息
socket.addEventListener('message', (event) => {console.log('收到消息:', event.data);
});// 连接关闭时触发
socket.addEventListener('close', (event) => {console.log('WebSocket 连接已关闭');
});// 发生错误时触发
socket.addEventListener('error', (event) => {console.error('WebSocket 错误:', event);
});
服务器实现(Node.js with ws 库):
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });// 监听连接
wss.on('connection', (ws) => {console.log('新客户端连接');// 接收客户端消息ws.on('message', (message) => {console.log('收到:', message.toString());ws.send('服务器已收到: ' + message.toString());});// 连接关闭ws.on('close', () => {console.log('客户端已断开');});
});
🅴️ 其他跨域方案
方案 | 适用场景 | 原理 | 优缺点 |
---|---|---|---|
postMessage | 跨窗口/iframe 通信 | 窗口间通过 postMessage 方法传递数据 | 灵活但仅限窗口间通信 |
document.domain | 同主域不同子域 | 显式设置 document.domain 为相同主域 | 简单但仅限同主域场景 |
location.hash | iframe 通信 | 利用 URL 哈希值传递数据 | 兼容性好但数据量有限 |
window.name | iframe 通信 | 利用 window.name 属性存储数据 | 可存储大量数据但实现复杂 |
五、深度解析:CORS 预检请求
很多开发者在使用 CORS 时会遇到一个困惑为什么明明只发送了一个请求,浏览器网络面板却显示两个请求?这就是 CORS 的预检请求机制在起作用。
🕵️♂️ 什么是预检请求?
预检请求(Preflight Request) 是浏览器在发送某些跨域请求前,先发送一个 OPTIONS
方法请求到服务器,以确定服务器 是否允许实际请求。
🚦 触发预检请求的条件
当请求满足以下任一条件时浏览器会自动发送预检请求:
1. 使用非简单方法
简单方法包括:GET
、HEAD
、POST
非简单方法包括:PUT
、DELETE
、CONNECT
、OPTIONS
、TRACE
、PATCH
2. 使用非简单请求头
简单请求头包括:
Accept
Accept-Language
Content-Language
Content-Type
(仅允许值为application/x-www-form-urlencoded
、multipart/form-data
或text/plain
)
非简单请求头示例:
Authorization
(认证令牌)Content-Type: application/json
(JSON 格式数据)X-Custom-Header
(自定义头)
🔍 简单请求完整示例
满足以下条件的请求不会触发预检:
// 简单GET请求示例
fetch("https://api.example.com/data", {method: "GET",headers: {Accept: "application/json","Accept-Language": "zh-CN",},
});
🔍 预检请求完整示例
以下请求会触发预检:
// 带自定义头的POST请求(会触发预检)
fetch("https://api.example.com/data", {method: "POST",headers: {"Content-Type": "application/json", // 非简单Content-TypeAuthorization: "Bearer token123", // 非简单请求头"X-User-ID": "12345", // 自定义头},body: JSON.stringify({ name: "跨域请求" }),
});
预检请求/响应流程:
- 预检请求(OPTIONS):浏览器自动发送
OPTIONS /data HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type,Authorization,X-User-ID
- 预检响应:服务器返回
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type,Authorization,X-User-ID
Access-Control-Max-Age: 86400
- 实际请求:预检通过后发送真实请求
⏱️ 预检请求优化
频繁的预检请求会影响性能,可通过以下方式优化:
- 设置合理的 Max-Age:缓存预检结果(单位:秒)
- 避免使用自定义头:优先使用简单请求头
- 合并请求:减少跨域请求次数
- 使用 GET 替代 POST:GET 请求通常为简单请求
六、总结
CORS 预检请求机制是为了确保跨域请求的安全性而引入的。开发者在使用 CORS 时需要注意触发预检请求的条件,以及合理配置服务器端响应头。通过合理优化预检请求,能够提升应用的性能和用户体验。
希望本文能够帮助你理解跨域的本质、同源策略的作用,以及如何通过 CORS、JSONP、代理等多种方式解决跨域问题。跨域虽然是 Web 开发中的一个挑战,但也是提升应用安全性和用户体验的重要环节,好好利用可以让你的应用程序更加高效。😉