一文全面系统学会跨域

在这里插入图片描述

同源政策 same-origin policy

背景

cookie 可以在不同源的网站之间共享。

A网站是一家银行,用户登录以后,没退出。打开新标签页去浏览其他网站。如果这个 其他网站是个黑客网站,因为 cookie 可以在浏览器各个标签页中共享的,
很显然,如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,黑客网站就可以冒充用户。加上浏览器同时还规定,提交表单不受同源政策的限制。所以黑客网站可以冒充该用户向银行网站提交转账的表单。

这一切显然是不能接受的,cookie 不应该能共享。因此浏览器提出了同源政策。

同源政策的目的:保证用户信息的安全,防止恶意的网站窃取数据。

同源判定

所谓"同源"指的是"三个相同":

  1. 协议相同
  2. 域名相同
  3. 端口相同

同源限制

如果非同源,共有三种行为受到限制:

  1. 无法读取其他源的 Cookie、LocalStorage 和 IndexDB。
  2. 无法获得其他源的 DOM
  3. 无法向其他源发起 AJAX 请求

规避同源限制

Cookie、LocalStorage、IndexDB

Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。

LocalStorage 和 IndexDB 需要使用 PostMessage API。

DOM

二如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。

完全不同源的办法:

  • 片段识别符(fragment identifier)(URL的#号后面的部分)
  • window.name
  • 跨文档通信API(Cross-document messaging)PostMessage

AJAX

同源政策规定,AJAX请求只能发给同源的网址,否则就报错。

除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),还有三种方法规避这个限制:

  1. JSONP
  2. WebSocket
  3. CORS

Cookie 跨域共享

Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。

举例来说,A网页是http://w1.example.com/a.html,B网页是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享Cookie。

document.domain = 'example.com';

现在,A网页通过脚本设置一个 Cookie。

document.cookie = "test1=hello";

B网页就可以读到这个 Cookie。

var allCookie = document.cookie;

注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。

另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.example.com。

Set-Cookie: key=value; domain=.example.com; path=/

这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。

iframe 数据通信

如果两个网页不同源,就无法拿到对方的 DOM。典型的例子是 iframe 窗口和window.open方法打开的窗口,它们与父窗口无法通信。

其实与其说这里是拿到 iframe 中的 dom,不如说是与 iframe 之间的数据通信。

比如,父窗口运行下面的命令,如果iframe窗口不是同源,就会报错。

document.getElementById("myIFrame").contentWindow.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.

上面命令中,父窗口想获取子窗口的DOM,因为跨源导致报错。

反之亦然,子窗口获取主窗口的DOM也会报错。

window.parent.document.body
// 报错

如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。

片段识别符

片段标识符(fragment identifier)指的是,URL的#号后面的部分,比如http://example.com/x.html#fragment的#fragment。如果只是改变片段标识符,页面不会重新刷新。

父窗口可以把 dom 信息,写入子窗口的片段标识符。

var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;

子窗口通过监听hashchange事件得到父窗口传递的数据。

window.onhashchange = checkMessage;

function checkMessage() {
  var message = window.location.hash;
  // ...
}

同样的,子窗口也可以改变父窗口的片段标识符。

parent.location.href= target + "#" + hash;

window.name

浏览器窗口有window.name属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页也可以读取它。

父窗口先打开一个子窗口,载入一个不同源的网页,将该网页将信息写入window.name属性。

window.name = data;

接着,子窗口跳回一个与主窗口同域的网址。

location = 'http://parent.url.com/xxx.html';

然后,主窗口就可以读取子窗口的 window.name 了。

var data = document.getElementById('myFrame').contentWindow.name;

这种方法的优点是,window.name 容量很大,可以放置非常长的字符串;缺点是必须监听子窗口window.name 属性的变化,影响网页性能。

window.postMessage 跨页面通信

上面两种方法都属于找漏洞的奇淫巧技。
HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。

这个API为 window 对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。

举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了。

var popup = window.open('http://bbb.com', 'title');
popup.postMessage('Hello World!', 'http://bbb.com');

postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即"协议 + 域名 + 端口"。也可以设为*,表示不限制域名,向所有窗口发送。

子窗口向父窗口发送消息的写法类似。

window.opener.postMessage('Nice to see you', 'http://aaa.com');

父窗口和子窗口都可以通过message事件,监听对方的消息。

window.addEventListener('message', function(e) {
  console.log(e.data);
},false);

message事件的事件对象event,提供以下三个属性。

  • event.source:发送消息的窗口
  • event.origin: 消息发向的网址
  • event.data: 消息内容

下面的例子是,子窗口通过event.source属性引用父窗口,然后发送消息。

window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
  event.source.postMessage('Nice to see you!', '*');
}

event.origin属性可以过滤不是发给本窗口的消息。

window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
  if (event.origin !== 'http://aaa.com') return;
  if (event.data === 'Hello World') {
      event.source.postMessage('Hello', event.origin);
  } else {
    console.log(event.data);
  }
}

LocalStorage

通过window.postMessage,读写其他窗口的 LocalStorage 也成为了可能。

下面是一个例子,主窗口写入iframe子窗口的localStorage

window.onmessage = function(e) {
  if (e.origin !== 'http://bbb.com') {
    return;
  }
  var payload = JSON.parse(e.data);
  localStorage.setItem(payload.key, JSON.stringify(payload.data));
};

上面代码中,子窗口将父窗口发来的消息,写入自己的LocalStorage。

父窗口发送消息的代码如下。

var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(JSON.stringify({key: 'storage', data: obj}), 'http://bbb.com');

加强版的子窗口接收消息的代码如下。

window.onmessage = function(e) {
  if (e.origin !== 'http://bbb.com') return;
  var payload = JSON.parse(e.data);
  switch (payload.method) {
    case 'set':
      localStorage.setItem(payload.key, JSON.stringify(payload.data));
      break;
    case 'get':
      var parent = window.parent;
      var data = localStorage.getItem(payload.key);
      parent.postMessage(data, 'http://aaa.com');
      break;
    case 'remove':
      localStorage.removeItem(payload.key);
      break;
  }
};

加强版的父窗口发送消息代码如下。

var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
// 存入对象
win.postMessage(JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com');
// 读取对象
win.postMessage(JSON.stringify({key: 'storage', method: "get"}), "*");
window.onmessage = function(e) {
  if (e.origin != 'http://aaa.com') return;
  // "Jack"
  console.log(JSON.parse(e.data).name);
};

AJAX

同源政策规定,AJAX请求只能发给同源的网址,否则就报错。

除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。

  1. JSONP
  2. WebSocket
  3. CORS

jsonp

Jsonp (JSON with Padding)

WebSocket

WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。

下面是一个例子,浏览器发出的WebSocket请求的头信息(摘自维基百科)。

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。

正是因为有了Origin这个字段,所以WebSocket才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

CORS

CORS 是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是 W3C 标准,是跨源 AJAX 请求的根本解决方法。相比JSONP只能发GET请求,CORS 允许任何类型的请求。

cors 相当于白名单机制。

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。

请求方法是以下三种方法之一:

  1. HEAD
  2. GET
  3. POST

HTTP的头信息不超出以下几种字段:

  1. Accept
  2. Accept-Language
  3. Content-Language
  4. Last-Event-ID
  5. Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

凡是不同时满足上面两个条件,就属于非简单请求。

可见,我们最常用的虽然是 get post,但 Content-Type 字段的类型通常为 application/json。所以属于非简单请求。

CORS —— 简单请求

浏览器发现这次跨源 AJAX 请求是简单请求,就自动在头信息之中,添加一个Origin字段。Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。

服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequest 的 onerror 回调函数捕获。
注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以 Access-Control- 开头。

Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个*,表示接受任意域名的请求。

Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许浏览器发送 Cookie。默认情况下,跨域请求不会携带 cookie。
设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送 Cookie,删除该字段即可。

Access-Control-Expose-Headers

该字段可选。表示暴露给客户端的响应头。

CORS 请求时,XMLHttpRequest 对象的 getResponseHeader() 方法默认只能拿到 6 个基本响应头:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma

如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。

withCredentials & credentials

上面说到,跨域请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials 字段为 true。

另一方面,开发者必须在AJAX请求中打开withCredentials属性:

// XMLHttpRequest
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

fetch 就是 credentials 配置项:

// fetch
fetch('http://another.com', {
  credentials: "include"
});

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials

需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin 就不能设为星号,必须指定明确的、与请求网页一致的域名。

同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。

CORS —— 非简单请求

预检请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。

浏览器发现该请求是一个跨域请求,就会先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。
只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest 请求,否则就报错。

"预检"请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是 Origin,表示请求来自哪个源。

OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

除了Origin字段,"预检"请求的头信息包括两个特殊字段。

Access-Control-Request-Method

该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是 PUT。

Access-Control-Request-Headers

该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段。上例是X-Custom-Header。

预检请求的响应

服务器收到"预检"请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

如果服务器返回了一个正常的HTTP回应,但是没有任何 CORS 相关的头信息字段。这就说明服务端否定了这次的跨域请求。
这时,浏览器也就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror 回调函数捕获。控制台会打印出跨域最常见的报错信息。

XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

服务器回应的其他CORS相关字段如下:

Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

Access-Control-Allow-Methods

该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。
注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。

Access-Control-Allow-Headers

如果浏览器请求头中包括了 Access-Control-Request-Headers 字段,则响应时, Access-Control-Allow-Headers 字段就是必需的。
它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。

Access-Control-Allow-Credentials

该字段与简单请求时的含义相同。

Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

浏览器的正常请求和回应

一旦服务器通过了"预检"请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

下面是"预检"请求之后,浏览器的正常CORS请求。

PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。

下面是服务器正常的回应。

Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,**Access-Control-Allow-Origin**** 字段是每次回应都必定包含的。**

解决 AJAX 跨域

同域部署

同域部署是一种简单有效的避免跨域问题的方式。其核心思想是将前端静态资源和后端服务部署在同一个域下,这样前端发送的请求都会在同一域名下进行,从而避免了浏览器的同源策略限制。

通过 location 判断 URL 判断是请求的前端还是接口。如果是接口,则反向代理到真正的服务端服务。

实现步骤:

  1. 准备前端代码
    • 前端代码通常包括HTML、CSS、JavaScript等静态资源。
    • 使用构建工具(如Webpack、Gulp等)将前端代码打包,准备部署。
  2. 准备后端服务
    • 后端服务通常是API接口、数据库操作等。
    • 确保后端服务能够处理前端发送的请求。
  3. 配置Web服务器
    • 选择一个Web服务器,如Apache、Nginx等。
    • 配置服务器,使其能够处理前端静态资源和后端服务请求。
  4. 部署前端代码
    • 将打包好的前端代码部署到Web服务器的特定目录下,例如/var/www/html
  5. 部署后端服务
    • 将后端服务部署到同一台服务器上,可以选择不同的目录,如/api
  6. 配置服务器路由
    • 修改Web服务器的配置,设置路由规则,使得不同的请求路径映射到不同的处理方式。
    • 例如,对于Nginx,可以设置当请求为HTML、CSS、JS等静态资源时,直接从部署前端代码的目录中获取;当请求为API接口时,转发到后端服务的地址。
  7. 域名解析
    • 将域名解析到部署了前端和后端的服务器IP地址。
  8. 测试
    • 在浏览器中访问域名,测试前端页面是否能够正常加载,并且与后端服务的交互是否正常。

示例:Nginx配置

假设前端代码部署在/var/www/html目录下,后端服务运行在http://localhost:3000

server {
    listen 80;
    server_name example.com;
    location / {
        root /var/www/html;
        try_files $uri $uri/ /index.html;
    }
    location /api/ {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

在这个配置中,当用户请求example.com时,Nginx会检查/var/www/html目录下是否有匹配的静态资源,如果有,则直接返回静态资源;如果没有,则返回index.html。当请求路径以/api开头时,Nginx会将请求代理到本地的后端服务http://localhost:3000

通过这种方式,前端和后端都在同一个域下,避免了跨域问题。

代理转发原始请求信息

在Nginx配置文件中,location /api/ 块定义了当请求路径以 /api 开头时,Nginx 应该如何处理这些请求。在这个块中,proxy_pass 和相关的 proxy_set_header 指令用于配置反向代理的行为。下面是每个字段的含义:

  1. proxy_pass http://localhost:3000;
    • 这个指令告诉 Nginx 将匹配 /api/ 的请求代理到 http://localhost:3000 这个地址。这意味着所有到 /api/ 的请求都会被转发到本地的3000端口上运行的服务。
  2. proxy_set_header Host $host;
    • 这个指令设置了转发请求时的 Host 头部。$host 是一个 Nginx 变量,它包含了客户端请求的原始 Host 头部值。这样,后端服务就会收到与原始请求相同的 Host 头部,这对于后端服务的虚拟主机配置和请求路由非常重要。
  3. proxy_set_header X-Real-IP $remote_addr;
    • 这个指令设置了 X-Real-IP 头部,将其值设置为 $remote_addr,即客户端的 IP 地址。这样,后端服务就可以知道请求实际上来自哪个 IP 地址,这在日志记录和访问控制等方面非常有用。
  4. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    • 这个指令设置了 X-Forwarded-For 头部,用于识别通过 HTTP 代理或负载均衡器传递过来的客户端原始 IP 地址。$proxy_add_x_forwarded_for 变量包含客户端请求的 X-Forwarded-For 头部值,如果客户端请求没有这个头部,则变量值为空。Nginx 会将客户端的 IP 地址追加到这个头部值后面,这样后端服务就能获取到完整的客户端 IP 地址链。

这些指令共同作用,确保了代理转发的请求能够保留足够的原始请求信息,以便后端服务能够正确处理这些请求。同时,它们也帮助维护了服务器的安全性和请求的透明性。

代理

代理分为正向代理和反向代理。应用场景不一样,前者一般是前端开发时用,后者是正式环境使用。

正向代理 —— 开发服务器

构建工具代理服务

开发 SPA 应用时,往往会启动开发服务器,热更新,时时查看编辑效果。那开发服务必定和后端服务发生跨域。

我们知道同源策略只有浏览器才有,所以构建工具不仅开启了一个静态资源服务进行热更新,还可以通过插件开一个代理服务转发客户端的请求。
注意:代理服务不是又起了一个 node HTTP 服务,浏览器面对的只有一个 node HTTP 服务。代理服务是在原本的开发服务上添加了一个接口。所以热更新和代理服务对浏览器都是同源的,没有跨域。

构建工具搭建代理服务一般都使用了 http-proxy-middleware

webpack 搭建本地服务器

自建代理服务

当然,我们也可以不使用构建工具提供的代理服务,自己搭建一个代理服务。

但要保证两点:

  1. 代理服务器和浏览器之间要能处理跨域情况,因为开发服务和自己搭建的代理服务肯定跨域了。
  2. 代理服务器要拿到浏览器的请求 url 进行重写,拼接出真正的 api 服务器 url,然后拿着这个 url 像目标服务器发起请求。
// include dependencies 
var express = require('express');
var proxy = require('http-proxy-middleware');

// proxy middleware options 
var options = {
  target: 'http://www.example.org', // target host 
  changeOrigin: true,               // needed for virtual hosted sites 
  ws: true,                         // proxy websockets 
  pathRewrite: {
    '^/api/old-path' : '/api/new-path',     // rewrite path 
    '^/api/remove/path' : '/path'           // remove base path 
  },
  router: {
    // when request.headers.host == 'dev.localhost:3000', 
    // override target 'http://www.example.org' to 'http://localhost:8000' 
    'dev.localhost:3000' : 'http://localhost:8000'
  }
};

// create the proxy (without context) 
var exampleProxy = proxy(options);

// mount `exampleProxy` in web server 
var app = express();
app.use('/api', exampleProxy);
app.listen(3000);

反向代理

nginx 反向代理服务器和 node 代理服务器本质是一样的,都是代理服务器,都需要设置为和浏览器同源或者对浏览器允许跨域,告诉浏览器不要限制。

具体操作已经在同源部署中讲过了。这里补充一下重写 URL rewrite。

server {
  listen 1111;
  server_name localhost;

  location / {
    root html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html;
  }

  # path 匹配到 /api 就将请求转到 http://127.0.0.1:2222
  location /api { 
    rewrite /api/(.*) /$1 break; # 重写 URL (可选)
    proxy_pass http://127.0.0.1:2222;
  } 
}

上面这种反向代理后台服务的方式。以浏览器的视角来讲,它访问的都是一个源:http://127.0.0.1:1111。
只是请求发过去,返回的是静态 HTML 文件,还是后端接口的数据,都靠 location 字段设置的 URL 匹配规则来区分,比如接口服务一般匹配 path 为/api,所以前端中请求接口数据,url 必须带上/api。

注意:可能需要使用到rewrite,它可以重写 url ,然后将重写后的 url 传递给后续服务。

rewrite <正则表达式匹配浏览器过来的url> <$1拿到匹配的结果,可重新组成新的url> break(结束);

什么情况下需要重写 url 呢?

我们知道浏览器获取到HTML,还是接口数据,靠的就是请求url中多了一个/api

  • /user 表示请求 user 页面
  • /api/user 表示请求 user 接口数据

此时浏览器向 /api/user 发起请求,请求被转发给了 http://127.0.0.1:2222 接口服务。如果后端的服务接口路由没有 /api 是/user@GetMapping("/user")),那请求就匹配不上了,404 报错。这个时候就要重写 url,把 /api 给去掉。以 http://127.0.0.1:2222/user 的url去访问后台接口。
如果服务接口写的就是/api/user@GetMapping("/api/user")),那就不要重写 url 了。

CORS 解决跨域

CORS 解决跨域核心就四个字:加响应头。

网关 nginx

网关中加响应头,如 NGINX。

server {
    listen 80;
    server_name example.com;
    location / {
        root /var/www/html;
        try_files $uri $uri/ /index.html;
    }

    location ^~ /api/ {
        
        # 配置代理转发到 localhost:3000
        proxy_pass http://localhost:3000;
        
        # 设置代理请求头,转发原始请求信息
        proxy_set_header Host $http_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;
        
        # 添加跨域请求头
        add_header 'Access-Control-Allow-Origin' $http_origin;
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' '*';
        
        # 处理 OPTIONS 请求
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $http_origin;
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
            add_header 'Access-Control-Allow-Headers' '*';
            add_header 'Access-Control-Max-Age' 1728000;  # 缓存预检请求 20 days
            add_header 'Content-Type' 'text/plain; charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }
        
    }

}

看到这可能会疑惑,这不是和同域部署是一样的吗?就是多加了一些跨域响应头。
是不一样的,在后端中感受不到区别。前端可以感受到。区别在于前端在写接口的时候,如果没有添加这些响应头,这前端请求接口的 baseURL 是固定的,必须是 NGINX 的 URL 加上 /api 。因为 NGINX 这个代理服务器没有支持对前端的跨域。
如果加了跨域响应头,这 NGINX 支持跨域了,任何前端只要都可以访问该后台服务的接口了。

同源部署的方式限制了能访问接口的前端,安全性更高,所以这种方式用的也很多。如果想 NGINX 开启了 cors 并且也限制能访问的前端。

除了写死 Access-Control-Allow-Origin,还可以通过 if 正则判断。这是常用的做法。

http {
    # 其他 HTTP 配置...

    server {
        listen 80;
        server_name example.com;

        # 允许跨域请求的配置
        location ^~ /api/ {
            # 只允许特定域名访问 example.com
            if ($http_origin ~* (http(s)?://(www\.)?example.com(:[0-9]+)?) {
            
                proxy_pass http://localhost:3000;
                proxy_set_header Host $http_host;
                
                ...
            
            } else {
                return 403;
         }
      }
   }
}

node.js

可以原生设置响应头和处理 options 请求,还可以通过 cors 库便捷设置。

const corsOptions = {
  origin: 'https://example.com', // 允许来自 https://example.com 的请求
  methods: 'GET, POST, PUT', // 允许的 HTTP 方法
  allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头部
  exposedHeaders: ['X-Total-Count'], // 暴露给客户端的响应头部
  credentials: true, // 允许发送凭证信息(如 Cookie)
  maxAge: 86400, // 预检请求结果的缓存时间(24小时)
  preflightContinue: false, // 不发送非 CORS 相关的响应头部
  optionsSuccessStatus: 200 // 预检请求的成功状态码
};

app.use(cors(corsOptions));

如果您想要允许所有来源和所有 HTTP 方法,可以简单地使用 app.use(cors()) 而不传递任何配置对象。

spring boot

SpringBoot设置Cors跨域的四种方式

其他

fetch - mode 配置

fetch 的 mode 选项是一种安全措施,可以防止偶发的跨源请求:

  • “cors” —— 默认值,允许跨源请求,如 Fetch:跨源请求 一章所述,
  • “same-origin” —— 禁止跨源请求,
  • “no-cors” —— 只允许跨域请求中的简单请求,什么是简单请求看上文。
    • 这种模式通常用于加载跨域资源,如图片或脚本。

当 fetch 的 URL 来自于第三方,并且我们想要一个“断电开关”来限制跨源能力时,此选项可能很有用。

另外再次强调

  • fetch 默认不会发送跨域 cookie,除非你使用了 credentials 的初始化选项。(自 2018 年 8 月以后,默认的 credentials 政策变更为 same-origin。Firefox 也在 61.0b13 版本中进行了修改)

<a> 会跨域吗

由于<a>标签只是触发浏览器导航行为,它不涉及读取或写入另一个源的数据,因此不受同源策略的限制。
当你点击一个<a>标签时,浏览器会直接加载新的页面,如果新页面与当前页面不同源,浏览器会创建一个新的页面实例,并且不会将当前页面的任何敏感数据(如Cookie、LocalStorage等)带到新页面。

a 标签的请求是同步请求,不是 ajax。

script 标签引入脚本是异步的,为什么它没被同源限制,因为这是浏览器开后门故意设计的。

为什么<script> 可以跨域

<script> 标签加载的 JavaScript 脚本不受同源策略的限制,这主要是由于浏览器实现和历史原因造成的。同源策略(Same-Origin Policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果网页违反了同源策略,那么浏览器会限制从脚本中发起的跨域HTTP请求。

以下是几个关键点解释为什么 <script> 标签可以跨域加载资源:

  1. 历史原因:早期浏览器并没有严格执行同源策略。在互联网早期,跨域请求的需求并不像现在这么普遍,因此 <script> 标签被允许从不同来源加载资源,以便于网站可以包含来自不同域的 JavaScript 库或广告等。
  2. 动态执行<script> 标签可以动态生成并加载远程脚本。这种方式在早期的网页中很常见,比如动态插入广告脚本。如果 <script> 受同源策略限制,这些用例将无法实现。
  3. CORS的引入:随着网络技术的发展,为了安全地支持跨域请求,CORS(Cross-Origin Resource Sharing)标准被引入。CORS允许服务器声明允许哪些网站的脚本访问其资源。但 <script> 标签的跨域行为已经成为既定事实,为了向后兼容,它被保留了下来。
  4. 只执行不读取:虽然 <script> 标签可以跨域加载脚本,但这些脚本执行时仍受同源策略的限制。这意味着脚本可以执行,但不能读取跨域资源。例如,一个跨域加载的脚本不能读取不同源的文档内容或发起跨域的 AJAX 请求。

因此,尽管 <script> 标签可以跨域加载脚本,但为了安全起见,当涉及到敏感数据或需要跨域访问资源时,应当使用更安全的方法,如 JSONP、CORS 配置、代理服务器等。

  • 13
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值