跨域 & 跨站

这是一次组内的学习分享,按照自己学习的过程作为时间线来讲述

Part1 跨域

浏览器的同源策略

故事的开始,这是我第一次接触到跨域:

1
Access to XMLHttpRequest at 'http://localhost:3000/' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

先来看看为什么浏览器会报错:

浏览器的同源策略:作为安全策略,它用于限制一个 origin 的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。——《MDN 浏览器的同源策略》

所以这个报错是因为我们触发了浏览器的同源策略导致的。

那什么是同源呢?从下图可知:

所以当两个 URL 协议、域名、端口全都相同的时候,这两个 URL 是同源。

但是很多情况下,我们页面会引用到外部的cssjs文件,以及通过像img video audio 这类 HTML 标签引入外部资源,这都属于跨域。

为了避免页面请求不到外部文件导致样式错乱、或导致图片无法加载的问题,同源策略规定了通过 HTML 标签引入外部文件的时候予以放行。例如:

  • <img>:引入外部图片
  • <link>:引入外部 css
  • <script>:引入外部 javascript
  • …..

以上的情况属于跨域资源嵌入,这一般是允许的,但是跨域读操作却是被禁止的!!

而像发送Ajax请求、获取DOMjs对象都属于跨域读操作。

我的Ajax请求也正因为这个原因被同源策略禁止了。

转念一想,自从前后端分离后,前端网页和业务数据接口服务器常常不在一起,分属不同的域名或者使用不同的端口,前端请求接口显然会发生跨域,那业内普遍都是如何解决的呢?

跨域的多种解决方案

  • JSONP

”既然规则中允许加载外部 JS 文件,我们为什么不利用它来实现外部接口的请求呢?“

  • Nginx 代理

“因为跨域现象只在浏览器中存在,所以只要代理服务器将跨域请求转发即可”

比如前端运行在localhost:8080, 接口放在localhost:9527

http://localhost:8080/ 发送请求到http://localhost:8080/api/getData

通过代理后它其实获取的是 http://localhost:9527/getData 接口的数据。

下面是 conf 文件夹下的 nginx.conf 配置文件的内容:

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
user  root owner;
worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       8080;
        server_name  localhost;
        location / {
        #   root 为前端代码根路径
            root   /Users/caohanwen/cross-domain/web; #mac环境
            index  index.html index.htm;
        }
        #后台服务配置,配置了这个location便可以通过http://域名/xxxx 访问
        location ^~ /api/ {
						proxy_pass http://localhost:9527/;
            proxy_redirect  off;
            proxy_set_header  Host 127.0.0.1;
            proxy_set_header  X-Real-IP $remote_addr;
            proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   Cookie $http_cookie;
            allow  all;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
    include servers/*;
}
  • Webpack

通过 webpack-dev-server 代理服务转发跨域请求,和 Nginx 一样都是通过代理实现

package.json 文件,在 devDependencies 前加入如下代码:

1
2
3
"scripts": {
    "serve": "webpack-dev-server"
}

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
  entry: "./index.js",
  devServer: {
    port: 8080,
    host: "0.0.0.0",
    hot: true,
    proxy: {
      "/api": {
        target: "http://localhost:9527", //服务地址
        ws: false,
        changeOrigin: true,
        pathRewrite: { "^/api": "" },
      },
    },
  },
}

在命令行中执行命令yarn serve 启动开发服务,会看浏览器打开地址为 http://0.0.0.0:8080/ 的页面,发送请求到http://localhost:8080/api/getData ,通过代理后它其实获取的是 http://localhost:9527/getData 接口的数据。

需要注意的是:用 webpack 开发环境处理了跨域,打包后要部署到生产环境的代码只是静态文件,是没有解决跨域的,不要误以为在开发环境用 webpack 处理了跨域,生产环境也就处理好了。

  • postMessage+iframe(拍机堂与中台的收银台交互场景)

window.postMessage(message, targetOrigin) 方法是 html5 新引进的特性,可以使用它来向其它的 window 对象发送消息,无论这个 window 对象是属于同源或不同源

下面是父页面通过 iframe 嵌入子页面后,用postMessage通信的 code demo:

父页面(localhost:80):

1
2
3
4
5
6
7
<div>
  <p class="title" onClick="this.sendMsg">发送消息</p>
  <iframe
    id="child"
    src="[http://localhost:81](http://192.168.169.1:81/test1)"
  ></iframe>
</div>
1
2
3
4
5
sendMsg = () => {
  document
    .getElementById("child")
    .contentWindow.postMessage("hello", "http://localhost:81")
}

子页面(localhost:81):

1
<span id="message"></span>
1
2
3
4
5
6
7
8
window.addEventListener(
  "message",
  function (event) {
    document.getElementById("message").innerHTML =
      "收到" + event.origin + "消息:" + event.data
  },
  false
)
  • CORS(W3C标准 Cross-Origin Resource Sharing,跨域资源共享)

    CORS原理

CORS(跨源资源共享)是 W3C 的一个工作草案,定义了在访问跨源资源时,浏览器与服务器应该如何沟通。 Firefox 3.5+、Safari 4+、Chrome、iOS 版 Safari 都通过 XMLHttpRequest对象实现了对 CORS 的原生支持。在尝试打开不同来源的资源时,无需额外编写代码就可以触发这个行为。

——摘自《Javascript高级程序设计》

CORS应该算是现在最为推荐的跨域处理方案。不像JSONP那样投机取巧,走的是正规路子,不仅适用于各种Method,而且更加方便和简单。当然只有现代浏览器支持

他们在正式的跨域请求之前,先发送了一个OPTIONS请求去询问服务器是否允许接下来的跨域请求

“这怎么个询问法呢?”

浏览器和网站服务器商定了一下,在OPTIONS请求里新增了几个字段:

  • Origin:发起请求原来的域
  • Access-Control-Request-Method:将要发起的跨域请求方式
  • Access-Control-Request-Headers:将要发起的跨域请求中包含的请求头字段

服务器在响应字段中来表明是否允许这个跨域请求,浏览器收到后检查如果不符合要求,就拒绝后面的请求

  • Access-Control-Allow-Origin:允许哪些域来访问(*表示允许所有域的请求,但不能携带cookie
  • Access-Control-Allow-Methods:允许哪些请求方式
  • Access-Control-Allow-Headers:允许哪些请求头字段
  • Access-Control-Allow-Credentials是否允许携带Cookie
  • Access-Control-Max-Age 询问结果的有效期(下面细说)

可以从下图来理解预请求的过程:

“每次请求都要发送预请求是不是太麻烦了???”

这里为了避免每次都要询问,CORS做了两个重要的优化:

1.第一,如果是一个简单请求,那就直接发起请求,只需在请求中加入Origin字段表明自己来源,在响应中检查**Access-Control-Allow-Origin**,如果不符合要求就报错。

2.服务器响应字段中有一个Access-Control-Max-Age,它表明了这个询问结果的有效期,只要在有效期内,也不必再次询问。

那么下面就用CORS解决我遇到的跨域问题:

因为只需要后台做处理就可以,前端直接访问接口全路径。

Node实现如下:

demo1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const express = require('express')
const app = express()
const port = 9527

//设置跨域访问
app.all('*', function (req, res, next) {
	res.header("Access-Control-Allow-Origin", "**"); //这个表示任意域名都可以访问,但不能携带cookie了
	res.header("Access-Control-Allow-Headers", "X-Requested-With");
	res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
	res.header("X-Powered-By", ' 3.2.1')
	res.header("Content-Type", "application/json;charset=utf-8");
	next();
})

const server = app.listen(port, () => {
	console.log(Example app listening at <http://localhost>:${port})
})

demo2:

$ npm install cors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var express = require('express')
var cors = require('cors')
var app = express()

var corsOptions = {
  origin: 'http://example.com', //只有example可以访问
  optionsSuccessStatus: 200 
}

app.get('/products/:id', cors(corsOptions), function (req, res, next) {
  res.json({msg: '只有example.com可以访问'})
})

app.listen(80, function () {
  console.log('CORS-enabled web server listening on port 80')
})

到这里,我遇到的跨域问题已经解决了,这里总结一下都学到了什么:

  • 浏览器的同源策略
  • 同源策略对跨域操作有什么限制
  • 跨域的多种解决方案
  • CORS原理
  • node如何实现cors

Q&A

有了跨域知识基础,现在我们看另外两个问题:

a.跨域请求是否会主动携带cookie

默认情况下,跨源请求不提供凭据(cookie、HTTP 认证及客户端 SSL 证明等)。

通过将withCredentials 属性设置为 true,可以指定某个请求应该发送凭据。如果服务器允许带凭据的请求,会用下面的 HTTP 头部来响应:Access-Control-Allow-Credentials: true

——摘自《Javascript高级程序设计》

b. 跨域请求接口响应头设置 cookie 能否成功

在未发生跨域的情况下,cookie设置的流程:

  1. 客户端发送 HTTP 请求到服务器
  2. 当服务器收到 HTTP 请求时,在响应头里面添加一个 Set-Cookie 字段 ✅
  3. 浏览器收到响应后种下 Cookie
  4. 之后对该服务器每一次请求中都通过 Cookie 字段将 Cookie 信息发送给服务器。

最后经过在网上的搜寻再结合实测,得出结论:

  1. 前端设置withCredentials:true,后端即使不设置CORS头也可set-cookie成功
  2. 前端不设置withCredentials或者false,set-cookie响应头会被直接忽略

PART2 跨站

新的问题又出现了😵‍💫

Chrome版本升级后,很多中后台登录成功后Cookie无法传递导致登录状态失效

这段话翻译大致如下:Google发布的 Chrome 80 版本,将没有声明SameSite值的cookie默认设置为SameSite=Lax,以此来减少非安全第三方 cookie 的使用。

那么问题来了

1.第三方是什么?

2.SameSite属性是什么?

3.Chrome为什么要这样做,我们要如何应对?

第三方(跨站)是什么???😵‍💫

跨站是与同站相反的概念,同站是指两个 URL 的 eTLD+1 相同, 其中,eTLD 表示有效顶级域名,注册于 Mozilla 维护的公共后缀列表(Public Suffix List)中,例如,.com、.co.uk、.github.io 等。eTLD+1 则表示,有效顶级域名+二级域名.

PS: 不需要考虑端口,但要考虑协议:在 Chrome 86/Firefox 79 中,浏览器增加了一个 Schemeful Same Site 的选项,将协议也增加到了 Same Site 的判断规则中。但是并不是完全的不等判断,可以理解是否有 SSL 的区别。例如 http:// 和 https:// 跨站,但 wss:// 和 https:// 则是同站,ws:// 和 http:/ 也算是同站。

举几个例子🌰:

www.baidu.com & www.taobao.com 跨站

www.m.taobao.com & www.pc.taobao.com 同站

jack.github.io & rose.github.io 跨站还是同站呢?

所以,如果我们本地请求 sjapi.aihuishou.com/recycler-api/quotation/document/query

本地URL需要是 xxx.aihuishou.com 这种格式,才不会跨站。

SameSite是什么?

SameSite 是HTTP响应头 Set-Cookie 的属性之一。

它允许您声明该Cookie是否仅限于同站或者同站上下文。

属性值 说明
None Cookie将在所有上下文中发送,即允许跨域发送
Lax(default) Cookies允许与顶级导航一起发送,并将与第三方网站发起的GET请求一起发送。这是浏览器中的默认值
Strict Cookies只会在第一方上下文中发送,不会与第三方网站发起的请求一起发送

以前 None 是默认值,但最近浏览器版本将 Lax 作为默认值,用来防御CSRF攻击。

我们中后台系统会不会被影响呢?

看看从 None 改成 Lax 到底影响了哪些地方的 Cookies 的发送:

请求类型 实例 以前 Strict Lax None
None <a href="..."></a> 发送cookie 不发送 发送cookie 发送cookie
预加载 <link rel="prerender" href="..."/> 发送cookie 不发送 发送cookie 发送cookie
GET表单 <form method="get" action="..."/> 发送cookie 不发送 发送cookie 发送cookie
POST表单 <form method="post" action="..."/> 发送cookie 不发送 不发送 发送cookie
Iframe <iframe src="..."/> 发送cookie 不发送 不发送 发送cookie
Ajax $.get("...") 发送cookie 不发送 不发送 发送cookie
Image <img src="..."/> 发送cookie 不发送 不发送 发送cookie

iframe 嵌入的 web 应用有很多是跨站的,都会受到影响。

由于我们 FAT/UAT/生产环境 的URL的 eTLD+1 都是 aihuishou.com , 和请求接口的URL的eTLD+1 一致, 所以除了我们开发环境和一些跨站的iframe会受到影响,其余的没什么影响。

跨站请求的解决方案

知道原理后,答案就显而易见了,设置 SameSite 为 none 即可!

以 Adobe 网站为例,查看请求可以看到:

这里有两点要注意的地方:

  • HTTP 接口不支持 SameSite=none
  • 需要 UA 检测,部分浏览器不能加 SameSite=none

因为我们用的大多数都是HTTP接口,所以不适合用该方案

既然不支持三方cookie,那么和跨域同理,也可以使用反向代理解决(nginx)

目前的解决方案是用SwitchHosts切换我们的hosts:

1
127.0.0.1  dev.aihuishou.com

如果使用的是Firefox,可以做如下配置,也能解决samesite导致的开发环境的问题:

Firefox打开 about:config

1
2
network.cookie.sameSite.laxByDefault :false
security.fileuri.strict_origin_policy: false

Q&A

学完了跨站,最后两个问题:

c.跨站请求能否携带 cookie

由于chrome80版本后,cookie的samesite属性的默认值从None变为了Lax,所以跨站请求默认不携带cookie。

d. 跨站请求接口响应头设置 cookie 能否成功

  1. 前端设置withCredentials:true,后端即使不设置CORS头也可set-cookie成功
  2. 前端不设置withCredentials或者false,set-cookie响应头会被直接忽略