今天整理一些有关于跨域的内容…

为什么需要跨域?

浏览器实施同源策略,这是一种基本的安全协议。该策略禁止来自不同域的JavaScript脚本与另一个域的资源进行交互。

其中,同源是指两个URL的协议、主机和端口号都一致

为什么浏览器需要这种限制呢?
“同源策略”是浏览器的安全机制,其目的是:
防止恶意网站读取你登录状态下的敏感信息(如银行、邮件、后台管理等)。
举个例子:
你在 bank.com 登录了账户,浏览器存有 cookie。如果没有同源限制,恶意网站 evil.com 可以伪造请求读取你的银行账户信息,这就是“跨站请求伪造(CSRF)”的根源之一。

跨域是指当前网页的源和要请求的目标接口的源不一致

由于浏览器同源策略的限制,将存在以下的跨域问题:

  1. 无法访问来自不同源网页的Cookie、LocalStorage和IndexedDB,也就是说不同源的网页之间不能共享存储数据
  2. 无法操作不同源网页的DOM,每个网页的DOM只能由其自己的脚本访问,不能被其他源的脚本操作。
  3. 无法向不同源地址发起AJAX请求,这限制了网页与不同源服务器之间的数据交互。

这些限制在确保Web应用安全性防止恶意网站访问其他网站的敏感数据,但同时也给开发跨域Web应用带来了挑战。

相应的解决方案有:

1.JSONP

浏览器对<script>标签没有跨域限制,通过动态创建script标签,请求后端返回可执行的代码。

只支持GET请求,使用于一些旧项目,无法配置CORS的第三方服务。

前端代码

1
2
3
4
5
6
7
8
9
<script>
function handleResponse(data) {
console.log('跨域数据:', data);
}

let script = document.createElement('script');
script.src = 'http://example.com/data?callback=handleResponse';
document.body.appendChild(script);
</script>

后端返回

1
handleResponse({ name: "OpenAI", type: "AI" });

也就是在请求的时候不仅要写接口路径,还要加一个回调函数名,并将完整的路径写入script的src中,后端返回数据的时候将函数名与内容拼接,这样就可以直接对返回的内容进行操作了。

2.document.domain

两个页面可以设置相同的document.domain,浏览器是通过这个属性来检查两个页面是否同源,只要设置相同的document.domain,两个页面就可以共享Cookie。

但是仅限于主域相同,子域不同的跨应用场景。比如a.example.com和b.example.com

1
2
// a.example.com 和 b.example.com
document.domain = "example.com"; // 都设置为主域

此时两个页面就可以互相访问DOM和JS了。逐渐被淘汰了。

3.CORS

跨资源共享标准新增了一组HTTP标头字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。

对于可能对服务器数据产生副作用的HTTP请求方法,浏览器必须先使用OPTIONS方法发起一个预检请求,从而获知服务器是否允许该跨源请求。服务器允许后,才发起实际的HTTP请求。

在预检请求的返回中,服务端也可以通知客户端是否需要携带身份凭证。

CORS请求失败会产生错误,但是为了安全,在JS代码层面无法获知到底具体是哪里出现了问题,只能在浏览器的控制台得知。

简单请求是指不会触发CORS预检请求的请求,

需要满足以下条件:

  • GET、HEAD、POST
  • 除了被用户代理自动设置的标头字段,允许人为设置的字段为Fetch规范定义的对CORS安全的标头字段集合(Accept、Accept-Language、Content-Language、Content-Type、Range)
  • Content-Type标头所指定的媒体类型仅限于:text/plain、multipart/form-data、application/x-www-form-urlencoded
  • 如果是使用XMLHttpRequest对象发出的请求,在返回的XMLHttpRequest.upload 对象属性上没有注册任何事件监听器
  • 请求中没有使用 ReadableStream 对象。

假如说站点https://foo.example的网页应用想要访问https://bar.other的资源。这个时候,浏览器发送给服务器的请求报文可能如下所示:

request
1
2
3
4
5
6
7
8
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

其中的Origin表明该请求来源于https://foo.example

而服务器的响应如下所示:

request
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

其中,服务端返回的Access-Control-Allow-Origin: *值表明,该资源可以被任意外源访问。

预检请求可以避免跨域请求对服务器的用户数据产生未预期的影响

与简单请求不同的是,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。

比如,以下是一个需要执行预检请求的HTTP请求,该请求包含了一个非标准的HTTPX-PINGOTHRT
请求标头,请求的Content-Type为application/xml,所以要发起预检请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
const fetchPromise = fetch("https://bar.other/doc", {
method: "POST",
mode: "cors",
headers: {
"Content-Type": "text/xml",
"X-PINGOTHER": "pingpong",
},
body: "<person><name>Arun</name></person>",
});

fetchPromise.then((response) => {
console.log(response.status);
});

首先是预检请求/响应

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

其中OPTIONS请求中携带了两个标头字段

  • Access-Control-Request-Method: POST,告知服务器实际请求将用POST方法。
  • Access-Control-Request-Headers: X-PINGOTHER, Content-Type,告知服务器实际请求将携带两个自定义请求标头字段。

服务器的响应中写明了允许的源域、允许的请求方法、允许的标头,最后的Access-Control-Max-Age给定了该预检请求可供缓存的的事件长短,默认5秒,在有效时间内,浏览器不需要为同一请求再次发起预检请求。

浏览器自身维护一个最大有效时间,如果超过最大有效时间将不会生效。

预检请求完成之后,将发送实际请求:

request
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
POST /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

<person><name>Arun</name></person>

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some XML payload]

但是如果预检请求发生了重定向,一部分浏览器会报错,解决办法有:

  1. 在服务端去掉对预检请求的重定向
  2. 将实际请求变成一个简单请求
  3. 发出一个简单请求判断真正的预检请求会返回什么地址,然后发送另一个请求在上一步通过response.url获得的URL

但是如果请求是由于存在Authorization字段而引发了预检请求,则这一方法无法使用

附带身份凭证的请求

一般而言,对于跨源的XMLHttpRequest或Fetch请求,浏览器不会发送身份凭证信息
,如果要发送,需要设置XMLHttpRequest对象的某个特殊标志位,或在构造Request对象时设置。

1
2
3
4
5
6
const url = "https://bar.other/resources/credentialed-content/";

const request = new Request(url, {credentials: "include"});

const fetchPromise = fetch(request);
fetchPromise.then((response) => console.log(response));

上面的代码构造了一个Request对象,并在构造器中将credentials选项设置为”include”,但是浏览器会拒绝任何不带Access-Control-Allow-Credentials: true标头的响应,且不会把响应提供给调用的网页内容

预检请求不能包含凭据,预检请求的响应必须指定Access-Control-Allow-Credentials: true来表明可以携带凭据进行实际的请求。

在响应附带身份凭证的请求时,

  1. 服务器不能将Access-Control-Allow-Origin 的值设为通配符“*”。
  2. 服务器不能将 Access-Control-Allow-Headers 的值设为通配符“*”。
  3. 服务器不能将 Access-Control-Allow-Methods 的值设为通配符“*”。

整理标头字段

响应标头字段:

  1. Access-Control-Allow-Origin
  2. Access-Control-Expose-Headers:指定标头放入允许列表中
  3. Access-Control-Max-Age
  4. Access-Control-Allow-Credentials
  5. Access-Control-Allow-Methods
  6. Access-Control-Allow-Headers

请求标头字段:

  1. Origin
  2. Access-Control-Request-Method
  3. Access-Control-Request-Headers

感觉这部分内容有一些难理解,做一个小小的类比…

你正在网站 https://foo.example 上写前端代码,你的代码像一个“游客”,想去另一个国家 https://bar.api.com

获取一些资源(比如用户数据、图片、JSON 数据等)。这就是一个跨域请求(因为两个域名不一样)。

浏览器是“边境警察”,负责检查这个请求是否合法。

🎬 第一幕:简单请求 —— 小事一桩,直接放行

你的小代码只是用 GET 或 POST 方法访问接口,也没有带什么奇怪的行李(特殊 header 或 JSON 内容),这时候警察觉得:

“嗯,你是个正常游客,看起来不危险。”

于是浏览器直接放你过去,但是提醒你一句:

“你可以去,但是我还得看看对面国家(服务器)愿不愿意接你。”

然后浏览器就在请求里加一个身份说明:

request
1
Origin: https://foo.example

这就像告诉服务端:

“喂!我这边来了一位游客,是从 https://foo.example 出发的,你愿不愿意接待他?”

如果服务器回答说:

request
1
Access-Control-Allow-Origin: https://foo.example

“欢迎欢迎,我们这儿允许你这位游客入境。”

那么浏览器就放心了,把服务器的响应结果传给网页里的 JS 用了!

但是如果服务器说:

request
1
Access-Control-Allow-Origin: *

“我们谁都欢迎!”(广结善缘)

那么浏览器只会允许普通请求,不允许携带 cookie 的请求通过(因为太泛了,怕泄露隐私)。

🎬 第二幕:预检请求 —— 带刀剑的游客必须先查一查

如果你发的请求不再那么“简单”了,比如:

  • 带了 Authorization、X-Custom-Header 这样的自定义头;
  • 请求方法是 PUT、DELETE 等;
  • 请求体是 application/json(不是默认的表单格式);

浏览器会马上警觉:

“这个游客行李太多、身份复杂,必须先发个探查请求去问一下那边边检站。”

于是浏览器会先发一个 预检请求(preflight request),这是一个 OPTIONS 请求,里面写着:

request
1
2
3
4
OPTIONS /api/data HTTP/1.1
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization

这意思就是:

“你好,我打算从 https://foo.example 发一个 POST 请求,带上 Authorization,请问你同意吗?”

如果服务器说

request
1
2
3
4
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Credentials: true

“我们查过啦,这个游客可以过关,请让他进来。”

浏览器才允许正式请求发出去!

如果服务器没回答清楚,比如:

  • 没有 Access-Control-Allow-Origin
  • 没有 Access-Control-Allow-Headers
  • Allow-Origin: * 却又想接收 Cookie

那浏览器就会说:

“不行!你那边手续不全,我不让这个请求出去。”

🎬 第三幕:请求携带身份凭证 —— 需要贴身安检!

如果你的请求想带 cookie、session 或者 Authorization 这种凭证,浏览器会特别小心。

你得主动告诉浏览器:

1
2
3
fetch("https://bar.api.com/data", {
credentials: "include"
})

就像告诉警察:

“这个游客带着身份证(cookie),得贴身检查!”

这时浏览器只会接受那些:

  • Access-Control-Allow-Origin 指定了你的网站(不能是 *)
  • Access-Control-Allow-Credentials: true 明确说:我接收带身份的游客!

否则浏览器直接说:

“你带身份证来,但对方不欢迎,结果我就把这个回应扔掉,不给 JS 用。”

有时候即使你做对了所有 CORS 的设置,浏览器仍然不让你带 cookie!

这是因为:很多浏览器默认会禁止跨站设置或发送 cookie,比如 Chrome、Safari 都开始阻止所谓的“第三方 cookie”(防广告追踪)。

这时你要在服务器设置:

request
1
Set-Cookie: token=abc123; SameSite=None; Secure

“这个 cookie 是允许跨站的,而且必须在 HTTPS 下使用!”

🎬 第四幕:重定向风波 —— 半路被拉去另一个国家

结果服务器 bar.api.com 回复说:

“哎哟,我不在这里办公啦,我们搬家了!请你去 https://api2.other.com/data。”

这时候就发生了 重定向(服务器返回 302/307)!

这时候浏览器会说:

“等会儿,我这是发预检请求,你怎么半路换个国家让我去?我可不干!”

4.Nginx反向代理

反向代理是一种服务器,它接收客户端的请求,并将这些请求转发给后端的真实服务器处理,然后将响应返回给客户端。

也就是说:

浏览器 → 反向代理(如 Nginx) → 后端服务

最终浏览器看到的是 Nginx,但 Nginx 在背后转发请求。

  1. 在 Nginx 配置文件中,为需要代理的每个服务设置一个特定的前缀。
  2. 配置Nginx将这些前缀的HTTP/HTTPS请求转发到对应的真实服务器。
  3. 通过这种方式,所有通过Nginx转发的URL都将具有相同的域名、协议和端口号,从而满足浏览器的同源策略要求。

由于所有 URL 都指向同一个服务器,浏览器将它们视为同源,从而避免了跨域访问的限制。实际上,这些 URL 背后是由不同的物理服务器提供服务。这样,服务器内部的JavaScript代码就可以自由地跨域调用这些服务器上的资源。

5.postMessage(跨窗口/iframe 通信)

在使用iframe或弹出窗口时,需要跨文档的消息传递。postMessage方法提供一种安全方式实现跨源通信,允许父窗口和子窗口之间进行消息交换。

发送方(iframe或子窗口)

1
window.opener.postMessage('你好,主窗口!', 'http://example.com');

接收方(主窗口)

1
2
3
4
window.addEventListener('message', (event) => {
if (event.origin !== 'http://iframe.com') return;
console.log('收到消息:', event.data);
});

6.WebSocket

WebSocket是HTML5的一个持久化协议,实现了浏览器与服务器的全双工通信。初次握手虽然为HTTP,但是连接建立好了之后客户端与服务器的通信就基于WS协议,与HTTP无关了。

服务端需监听 WebSocket 协议连接。,应用场景一般为实时数据,比如在线聊天室、股票推送等。

7.window.name / location.hash

某些浏览器特性允许在跨页面之间通过window.name或location.hash传递数据。

  • 页面 A 打开 B.html,B.html 设置 window.name = ‘数据’,再跳转回 A
  • 设置 B 的 location.hash = ‘#data’,B 从 location.hash 中读取

仅限页面跳转数据传递,不适合 AJAX 场景。