跨域问题

本文最后更新于 2025年8月25日 晚上

参考:
https://juejin.cn/post/6844904126246027278
https://juejin.cn/post/6844903767226351623

跨域问题

基本概念

一个 URL 地址的组成:

  • 协议(Protocol)、主机(Host)、端口(Port)、资源路径(Path)
  • 协议、子域名、主域名、端口号、资源路径

在浏览器实际定义中:

  • 源(Origin):由协议(Protocol)、主机(Host)、端口(Port)三者唯一确定
  • 域(Domain):通常指完整主机名(Host)
  • 站(Site):由有效顶级域名+二级域名(eTLD+1)决定

所以对于 https://developer.mozilla.org:443/zh-CN/docs/Web 这个地址来说:

  • 源是 https://developer.mozilla.org:443
  • 域是 developer.mozilla.org
  • 站是 mozilla.org ,所以有
  • 跨站一定是跨域,跨域不一定是跨站
  • 跨域一定是跨源,跨源不一定是跨域

跨域问题其实是浏览器的同源策略所导致的。客户端和服务端都没有跨域问题,因为客户端和服务端都没有域名。

跨域问题本质上是服务器的信任问题,因为数据都存在服务器,所以服务器的安全性需要保障。如何保障安全性,需要有个机制让服务器判断是否应该信任请求方,所以首先是标识请求方。前端页面可以用域名标识,服务端和客户端没有域名,但有其他方式解决信任问题,比如 access_token 等。

同源策略

同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,用户就很容易受到 XSS、CSRF 等攻击。同源指的是 “协议+子域名+主域名+端口号” 相同,即便两个不同域名指向同一个 IP 地址也非同源。

同源策略限制内容有:

  • Cookie、WebStorage、IndexedDB 等存储性内容
  • DOM 节点
  • AJAX 通信请求

但是有三个标签是允许跨源加载资源:

  • <img src=XXX>
  • <link href=XXX>
  • <script src=XXX>

相关场景

协议子域名主域名端口号中任意一个不相同都算不同源,不同源之间相互请求资源为“跨源”。子域名主域名中任意一个不相同都算不同域,不同域之间相互请求资源为“跨域”。下图是常见跨源场景:

常见跨源场景(来自掘金@浪里行舟)

特别说明:

  • 只会根据域名是否相同判断是否同域,不会根据域名对应的 IP 地址是否相同判断是否同域
  • 如果是因为协议和端口号不同而产生了跨源问题,前端无法干预解决
  • 如果是因为域名不同而产生了跨源问题(也是跨域问题),前端可以干预解决
  • 解决了因为浏览器的同源策略带来的跨源问题,也就解决了跨域问题,因为跨源的问题“更难”

跨源请求

跨源请求(大部分都是跨域请求)不是请求发不出去,请求能发出,服务端能收到请求并正常返回,只是结果被浏览器拦截。拦截跨源请求是为了阻止用户在一个源下读取另一个源下的内容。可以获取响应,但是浏览器认为这不安全,所以拦截响应;若是提交表单,因为不会获取新的内容,所以认为可以发起跨源请求。这同时也说明了跨源并不能完全阻止 CSRF 攻击,因为请求已经发出。

解决方案

CORS(生产常用)

CORS 是当前最主流、最推荐、也是 W3C 指定的标准跨域问题解决方式。

原理

跨源资源共享(CORS,或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问以及加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。

浏览器会自动进行 CORS 通信(请求都会发出),服务器开启了 CORS 就能够实现跨源请求。服务器通过设置一系列配置即可开启,核心配置有:

1
2
3
4
Access-Control-Allow-Origin: https://example.com // 或 *(非敏感数据)
Access-Control-Allow-Methods: GET, POST, PUT // 允许的 HTTP 方法
Access-Control-Allow-Headers: Content-Type // 允许的自定义头
Access-Control-Allow-Credentials: true // 允许携带 Cookie(需前端配合)

优缺点

优点:

  • 标准、安全、稳定
  • 支持 Cookie、Token 跨源
  • 后端控制粒度高(可限制 IP、接口、方法)
  • 所有浏览器支持

缺点:

  • 需后端配合,需修改服务端代码(如 Nginx 配置或后端中间件)

如果网站 A 和网站 B 是不同源的,网站 B 对网站 A 设置允许了 CORS,当网站 A 需要向网站 B 发起请求时,网站 A 想要使用网站 B 的 Cookie 需要同时满足以下三大条件:

  • 请求时携带跨源凭证
    在网站 A 请求时必须设置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // fetch 或 XMLHttpRequest
    fetch("https://B.com/api", {
    method: "GET",
    credentials: "include", // 关键
    });

    // axios
    axios.get("https://B.com/api", {
    withCredentials: true, // 关键
    });
  • 响应时携带跨源凭证
    网站 B 的响应头里的 Access-Control-Allow-Origin 不能写成 *,必须精确写出 A 的域名;
    Access-Control-Allow-Credentials 需为 true 表示允许携带 Cookie、HTTP 认证信息等。
    即网站 B 的响应头里必须设置:

    1
    2
    Access-Control-Allow-Origin: A
    Access-Control-Allow-Credentials: true
  • Cookie 属性设置
    如果网站 A 和 B 跨源又跨站,那么 B 的 Cookie 属性必须满足 SameSite=NoneSecure

    1
    Set-Cookie: session=123; SameSite=None; Secure; HttpOnly

    如果网站 A 和 B 跨源但没有跨站,不需要 SameSite=None 这么严格:
    网站 A 为 https://api.a.com,网站 B 为 https://sub.a.com
    网站 B 的 cookie 设置了 Domain=sub.a.com; SameSite=Lax
    网站 B 的响应头设置了 Access-Control-Allow-Credentials: true
    网站 B 的响应头设置了 Access-Control-Allow-Origin: A
    网站 A 向网站 B 请求资源时也设置了 credentials: "include"
    此时可以正常携带 cookie 请求并获取返回值

分类

通过 CORS 解决跨域问题时会在发送请求时出现两种情况,分别为简单请求和复杂请求。

简单请求

不会触发“预检”请求的请求为“简单请求”。同时满足以下五个条件,就属于简单请求。

条件 1,使用下列方法之一:

  • GET
  • HEAD
  • POST

条件 2,人为设置以下集合外的请求头:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (有额外限制)
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width

条件 3,Content-Type 的值仅限于下列三者之一:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

条件 4:请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器,XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

条件 5:请求中没有使用 ReadableStream 对象。

复杂请求

不符合简单请求的就是复杂请求。复杂请求会在正式通信前增加一次 HTTP 查询请求,被称为”预检”请求,该请求使用 OPTION 方法,通过该请求来知道服务端是否允许跨源请求。

示例

下面是一个完整的复杂请求的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// 前端

// index.html
let xhr = new XMLHttpRequest();
// Cookie 不能跨源
document.Cookie = "name=xiamen";
// 前端设置是否带 Cookie
xhr.withCredentials = true;
xhr.open("PUT", "http://localhost:4000/getData", true);
xhr.setRequestHeader("name", "xiamen");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
console.log(xhr.response);
// 得到响应头 后台需设置 Access-Control-Expose-Headers
console.log(xhr.getResponseHeader("name"));
}
}
};
xhr.send();

// 后端

// 用 PUT 向后台请求时,属于复杂请求,后台需做如下配置:
// 允许哪个方法访问我
res.setHeader("Access-Control-Allow-Methods", "PUT");
// 预检的存活时间
res.setHeader("Access-Control-Max-Age", 6);
// OPTIONS 请求不做任何处理
if (req.method === "OPTIONS") {
res.end();
}
// 定义后台返回的内容
app.put("/getData", function (req, res) {
console.log(req.headers);
res.end("我不爱你");
});

// server1.js
let express = require("express");
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// server2.js
let express = require("express");
let app = express();
let whitList = ["http://localhost:3000"]; //设置白名单
app.use(function (req, res, next) {
let origin = req.headers.origin;
if (whitList.includes(origin)) {
// 设置哪个源可以访问我
res.setHeader("Access-Control-Allow-Origin", origin);
// 允许携带哪个头访问我
res.setHeader("Access-Control-Allow-Headers", "name");
// 允许哪个方法访问我
res.setHeader("Access-Control-Allow-Methods", "PUT");
// 允许携带 Cookie
res.setHeader("Access-Control-Allow-Credentials", true);
// 预检的存活时间
res.setHeader("Access-Control-Max-Age", 6);
// 允许返回的头
res.setHeader("Access-Control-Expose-Headers", "name");
if (req.method === "OPTIONS") {
res.end(); // OPTIONS 请求不做任何处理
}
}
next();
});
app.put("/getData", function (req, res) {
console.log(req.headers);
res.setHeader("name", "jw"); // 返回一个响应头,后台需设置
res.end("我不爱你");
});
app.get("/getData", function (req, res) {
console.log(req.headers);
res.end("我不爱你");
});
app.use(express.static(__dirname));
app.listen(4000);

反向代理(开发常用)

原理

前端请求同源代理服务器,由代理服务器获取目标服务端资源(服务端间无跨源限制)。通过设置反向代理来“伪造同源”,绕过跨源问题。

优缺点

优点:

  • 调试方便,仅修改代理配置,前端代码无感知
  • 隐藏真实 API 地址,提升安全性,防止直接暴露后端服务

缺点:

  • 需维护代理层,生产环境需部署 Nginx 等代理服务
  • 性能损耗,增加一次网络跳转(可通过缓存优化)

示例

1
2
3
4
5
6
7
8
9
10
11
12
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "https://api.example.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});

JSONP

原理

利用 <script> 标签没有跨源限制的特性,网页可得到从其他来源动态产生的 JSON 数据。JSONP 一定要对方的服务器支持才可以。

优缺点

优点:

  • 简单兼容性好,可用于解决大部分浏览器的跨源数据访问的问题

缺点:

  • 仅支持 GET 方法具有局限性而且不安全,可能会遭受 XSS 攻击
  • 想使用完整的 REST 接口,需要使用 CORS 或其他代理方式

示例

  • 声明一个回调函数,把该函数名(如 show)当做参数值传递给跨源请求数据的服务器,函数的形参为要获取的目标数据(如服务器返回的 data)。
  • 创建一个<script>标签,把跨源的 API 数据接口地址赋值为 script 的 src,并且在这个地址中向服务器传递函数名(如?callback=show)。
  • 服务器接收到请求后进行处理:把传递进来的函数名和函数所需要给的数据拼接成一个字符串。例如传递的函数名是 show,那么准备好的数据就是 show(‘我不爱你’)。
  • 最后服务器把数据返回给客户端,客户端调用对应的回调函数对返回的数据进行操作。

在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,可以自己封装一个 JSONP 函数。下面这段代码相当于向http://localhost:3000/say?wd=Iloveyou&callback=show这个地址请求数据,然后后端返回 show(‘我不爱你’),最后会运行 show 这个函数,打印出’我不爱你’。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 前端
// index.html
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
let script = document.createElement("script");
window[callback] = function (data) {
resolve(data);
document.body.removeChild(script);
};
params = { ...params, callback }; // wd=b&callback=show
let arrs = [];
for (let key in params) {
arrs.push(`${key}=${params[key]}`);
}
script.src = `${url}?${arrs.join("&")}`;
document.body.appendChild(script);
});
}

jsonp({
url: "http://localhost:3000/say",
params: { wd: "Iloveyou" },
callback: "show",
}).then((data) => {
console.log(data);
});

// 后端
// server.js
let express = require("express");
let app = express();
app.get("/say", function (req, res) {
let { wd, callback } = req.query;
console.log(wd); // Iloveyou
console.log(callback); // show
res.end(`${callback}('我不爱你')`);
});
app.listen(3000);

postMessage

原理

window.postMessage 方法可以安全地实现跨源通信,其提供了一种受控机制来规避同源策略,只要正确的使用,这种方法很安全。postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,而且是为数不多可以跨源操作的 window 属性之一,它可用于解决以下方面问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的 iframe 消息传递
  • 上面三个场景的跨源数据传递

允许来自不同源的脚本用异步方式进行有限通信,可以用来实现跨文本档、多窗口、跨源消息传递。

示例

otherWindow.postMessage(message, targetOrigin, [transfer]);

  • otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames
  • message:将要发送到 otherWindow 的数据
  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接收到消息事件
  • transfer(可选):一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权

以下是http://localhost:3000/a.html向着http://localhost:4000/b.html传递“我爱你”,后者传回”我不爱你”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// a.html

// 等它加载完触发事件
<iframe
src="http://localhost:4000/b.html"
frameborder="0"
id="frame"
onload="load()"
/>

<script>
function load() {
const frame = document.getElementById('frame')
// 发送数据
frame.contentWindow.postMessage('我爱你', 'http://localhost:4000')
// 接受返回数据
window.onmessage = function(e) {
console.log(e.data) // 我不爱你
}
}
</script>

// b.html
<script>
window.onmessage = function(e) {
console.log(e.data) // 我爱你
e.source.postMessage('我不爱你', e.origin)
}
</script>

WebSocket

原理

WebSocket 是 HTML5 的一个持久化协议,它实现了浏览器与服务器的全双工通信,同时也是跨源的一种解决方案。WebSocket 和 HTTP 都是应用层协议,都基于 TCP 协议。WebSocket 是一种双向通信协议,在建立连接之后 WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关。WebSocket 不是解决 HTTP 跨源的通用方案。

原生 WebSocket API 使用起来不方便,可以使用Socket.io,它很好地封装了 WebSocket 接口,提供了更简单、灵活的接口,也对不支持 WebSocket 的浏览器提供了向下兼容。

示例

本地文件socket.htmllocalhost:3000发生数据和接受数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// socket.html
<script>
const socket = new WebSocket('ws://localhost:3000');
socket.onopen = function () {
socket.send('我爱你'); // 向服务器发送数据
}
socket.onmessage = function (e) {
console.log(e.data); // 接收服务器返回的数据
}
</script>

// server.js
const express = require("express");
const app = express();
const WebSocket = require("ws"); // 记得安装 ws
const wss = new WebSocket.Server({ port: 3000 });

wss.on("connection", function (ws) {
ws.on("message", function (data) {
console.log(data);
ws.send("我不爱你");
});
});

跨域问题
https://xuekeven.github.io/2021/09/25/跨域问题/
作者
Keven
发布于
2021年9月25日
更新于
2025年8月25日
许可协议