Skip to main content

跨域与解决方案

跨域

跨域本身不是计算机网络的一部分,属于浏览器自带的同源策略安全机制。

同源策略

Orgin

同源:协议,主 s 机,端口号三者全部相同,注意一般情况下省略端口号都是默认80端口的情况。在页面中通过javascript:方式打开的页面会继承当前 URL 的源。

  • 限制子域名不属于同源的,例如 mail.google.com 和 docs.google.com;
  • 限制不同端口的不属于同源,例如 google.com 和 google.com:8080

image-20200731214020188

具体限制

浏览器的同源策略是限制一个源的文档或者脚本如何与另一个源的资源进行交互的安全策略。例如在使用XMLHttpRequest或者<img>标签时都会受到同源策略的限制,这些限制通常有以下这些:

  • 跨域写操作:一般允许一个源向另一个源发送信息,但不允许一个来源从另一个来源接收信息
  • 跨域资源嵌入:一般也是被允许的,例如很多 HTML 标签引入的外部资源,具体有以下这些:
    • <script src="..."></script>标签嵌入跨域脚本
    • <link rel="stylesheet" href="...">嵌入 CSS,CSS 的跨域需要一个设置正确的 HTTP 头部 Content-Type
    • <img>标签
    • <video>标签
    • <audio>标签
    • <iframe>标签载入的任何资源
    • 一些通过 CSS@font-facesrc引入的字体,这个取决于不同浏览器的实现,有的允许,有的不允许
    • <object>data属性引入的外部资源
  • 跨域读操作:一般不被允许,例如使用XMLHttpRequest请求不同源 URL 的数据,使用 drawImageImages/video 画面绘制到 canvas;
  • 同源策略同样会限制存储在浏览器中的数据,lcoalStorageIndexDB都是以源为单位分割的,每个源都拥有自己单独的存储空间,一个源中的 JavaScript 脚本不能对属于其它源的数据进行读写操作;而cookie的定义比较宽松,页面可以为自己的域或任何父域设置 cookie,只要该父域不是顶级域名的形式即可,设置 cookie 时,可以使用 Domain,Path,Secure 和 HttpOnly 标志来限制其可用性。默认情况下cookie 会受到同源策略的限制,只能在同源 URL 请求发送,不受 URL 的路径部分影响,Chrome 从 80 版本以后新增了SameSite策略,对于不设置这个属性的 cookie,默认值为Lax,也就是 cookie 将在同源站点或者当前页面通过<a>跳转到其他页面时发送。

同源策略可以阻止网页恶意脚本,例如 JS 通过页面的 DOM 获取另一个网页中的数据。另外一种情况就是基于 cookie 的身份验证机制如果没有同源策略的限制,基本形同虚设。假设用户正在访问银行网站且未注销。然后,用户转到另一个站点,该站点的某些恶意 JavaScript 代码在后台运行,并从银行站点请求数据。由于用户仍在银行站点上登录,因此恶意代码可以执行用户在银行站点上可以执行的任何操作。这是来自一个不同源网页脚本的攻击行为,有了浏览器的同源策略就能轻易组织它。

好处

同源策略的最大好处是使得网页请求变得更加安全:

  • 可以阻止网页恶意脚本,尤其是基于cookie的身份验证和授权机制,现在绝大部分网站仍然使用这种方式来进行用户身份的识别。设想一下,假设一个用户正在访问银行网站且未注销。然后,用户转到另一个站点,该站点的某些恶意 JavaScript 代码在后台运行,并试图通过 DOM API 或者document.cookie来获取银行网页中的数据。这是来自一个不同源网页脚本的攻击行为,有了浏览器的同源策略就能轻易组织它。

不足

  • 同源策略无法阻止跨站伪造请求(CSRF)和点击劫持之类的攻击。

  • 同源策略给前端本地开发带来麻烦,本地服务器打开的页面访问服务器资源时就会受到限制。

跨域请求

下面是能够允许使用的跨域资源访问的方式,最常见的还是一些页面嵌入标签。

页面嵌入资源

  • <script src="..."></script>标签嵌入跨域脚本
  • <link rel="stylesheet" href="...">嵌入 CSS,CSS 的跨域需要一个设置正确的 HTTP 头部 Content-Type
  • <img>标签的 src 属性
  • <video>标签的 src 属性
  • <audio>标签的 src 属性
  • <iframe>标签载入的任何资源
  • 一些通过 CSS@font-facesrc引入的字体,这个取决于不同浏览器的实现,有的允许,有的不允许
  • <object>data属性引入的外部资源

<img>标签具有crossorigin属性,具有以下属性值

含义
anonymous对此元素的 CORS 请求将不设置凭据标志。
use-credentials对此元素的 CORS 请求将设置凭证标志;这意味着请求将提供凭据。
""设置一个空的值,如 crossorigincrossorigin="",和设置 anonymous 的效果一样。

当使用 canvas 操作图片时,如果从外部引入的 <img><svg>,并且图像源不符合规则,将会被阻止从 canvas 中读取数据;此时如果使用 canvas 对象的下列方法会报错

  • getImageData():返回指定区域的ImageData对象
  • toBlob:创建 Blob 对象
  • toDataURL():创建一个data://形式的字符串表示文件,可以认为是图片资源定位符,默认为 PNG 格式

CORS

CORS,Cross-Origin Resource Sharing,跨域资源共享是 W3C 针对跨源资源请求制定的标准方案,也是目前跨域请求主流的解决方案。

由于浏览器的同源策略限制,在页面 JS 中使用XMLHttpRequestFetch API时只能请求同源的 URL,CORS 则通过特定 HTTP 请求头和响应头参数来允许XMLHttpRequestFetch API执行跨源请求。

HTTP 请求头

浏览器在发起XMLHttpRequest请求时会自动添加这些请求头参数,无需在代码中手动添加。

参数含义备注
Origin当前请求发出的页面 URL 的域名字符串必传
Access-Control-Request-MethodHTTP 请求方法字符串;在发出预检请求时使用,以告知服务器发出实际请求时将使用哪种 HTTP 方法只在预检请求中使用
Access-Control-Request-Headers自定义的请求头参数,多个头部使用逗号分隔;在发出预检请求时使用,以告知服务器正式请求将携带的额外 HTTP 头部字段只在预检请求中使用

HTTP 响应头

服务器需要对允许跨域请求的响应返回以下请求头参数

参数含义是否必传
Access-Control-Allow-Origin可以是一个域名,告诉浏览器仅允许该域名访问资源;
也可以是*,表示允许任何源访问资源
域名/*
Access-Control-Allow-Headers允许使用的请求头部参数字符串
Access-Control-Allow-Methods允许的 HTTP 请求方法GET,POST 等
Access-Control-Max-Age指这个预检请求的响应可以被浏览器缓存多长时间
Access-Control-Allow-Credentials表示服务器是否结束了浏览器发送的凭据true/false
Access-Control-Expose-Headers允许浏览器读取的响应头参数Content-Type 等

现在主流浏览器基本都支持 CORS,只要服务端配置以下响应头部就可以顺利实现跨域资源请求,有一个网站叫Enable CORS,介绍了诸多服务端实现的方法,例如 nginx 的配置项

简单请求

浏览器会将使用XMLHttpRequest或者fetch发起的请求分为简单请求和非简单请求。

在简单请求中,浏览器会根据XMLHttpRequest的请求自动添加Origin请求头参数,在发出请求并获取响应之后,会根据响应头部的Access-Control-Allow-Origin字段判断当前域是否在服务器允许跨域请求的范围之内。因此要想允许跨域请求,只需要服务端设置响应头参数Access-Control-Allow-Origin即可。

请求头部自动添加

Origin: http://foo.example

响应头需要服务器手动添加

Access-Control-Allow-Origin: http://foo.example

简单请求只需要两步即可收到响应:

  • 浏览器添加Origin头部信息,发送请求;
  • 服务器返回响应头Access-Control-Allow-Origin浏览器收到后判断是否允许跨域请求,不允许则报错

image-20200802001326853

简单请求必须满足以下所有条件:

  • 使用GETHEADPOST请求

  • 只允许以下Content-Type

    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain:纯文本字符串
  • 除了使用用户代理自动设置的头部字段,只允许手动设置以下头部参数

  • 没有在请求中使用的任何XMLHttpRequestUpload对象上注册事件侦听器

  • 请求中未使用ReadableStream 对象

非简单请求

非简单请求就是不符合简单请求必须满足的所有条件,非简单请求一般是以下任一情况:

  • PUTDELETE等方法
  • 常用的Content-Type
    • application/json
    • text/xml

非简单请求发起时,浏览器会先使用OPTIONS方法发起一个预检请求到服务器,根据服务器返回的响应头部Access-Control-Allow-Origin以获知服务器是否允许该请求。

预检请求头额外参数

Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

在这个预检请求头参数中,Access-Control-Request-Method参数告知服务器在下一次发送时将使用POST请求,Access-Control-Request-Headers表示正是请求还将携带两个自定义的请求字段:X-PINGOTHER 和 Content-Type

预检请求体额外参数

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

服务器在收到预检请求后,肯定会发送一个Access-Control-Allow-Origin头部字段来告知浏览器,只有该参数的值允许跨域请求;如果服务器允许发送请求,对应请求时发送的Access-Control-Request-Method字段,服务端还会回应Access-Control-Allow-Methods字段,表示允许使用的请求方法;Access-Control-Allow-Headers对应于请求时Access-Control-Request-Headers字段,表示允许请求时发送的额外头部字段。

此外浏览器还会根据Access-Control-Max-Age:86400表明该响应的有效时间为 86400 秒,也就是 24 小时之内,浏览器无须为同一请求再次发起预检请求。值得注意的是,不同浏览器对于这个时间的上限要求不同:

  • FireFox 是最大 24 小时,也就是 86400 秒
  • 而 Chrome 在 V76 以后,最大值允许 2 小时,7200 秒
  • 如果值是-1,表示不允许缓存该预检请求,每次请求都必须先预检

非简单请求由于需要发送预检请求的原因,一般需要三次握手才能获取最终的响应结果:

  • 浏览器判断是非简单请求,使用OPTIONS发送预检请求;服务端收到预检请求,返回响应;
  • 浏览器判断服务端是否允许发送正式请求,允许使用允许的请求方法和头部发送正式请求;
  • 服务端收到正式请求,返回响应;浏览器接收响应

preflight_correct

附带身份凭证的请求

一般而言,对于XMLHttpRequestFetch,浏览器不会发送cookie等身份凭证信息,但是XMLHttpRequest支持withCredentials属性,将该属性设置为withCredential = true,则表示该请求会发送cookie,HTTP Basic authentication,或者客户端 SSL 证明等信息。

var ajax = new XMLHttpRequest();

if (invocation) {
ajax.open('GET', url, true);
ajax.withCredentials = true;
ajax.send();
}

如果服务器接受请求的凭据,会在响应头中添加:

Access-Control-Allow-Credentials: true

而如果响应头中没有Access-Control-Allow-Credentials: true,浏览器将不会把响应内容返回给请求的发送者,在XMLHttpRequest收到响应后,requestText将是空字符串,status值为0,而且会调用onerror()事件处理程序。

另外,对于附带身份凭证的请求,服务器不得设置 Access-Control-Allow-Origin 的值为“*”。这是因为请求的首部中携带了 Cookie 信息,如果 Access-Control-Allow-Origin 的值为“*”,请求将会失败。而将 Access-Control-Allow-Origin 的值设置为指定的请求域名如 http://foo.example.com,则请求将成功执行。

CORS 的一些缺陷

从 CORS 的机制了解到,在浏览器执行非简单请求时将会发送预检请求,等到服务端验证通过才去发送正式请求,这样的请求方式无端的多了一次建立连接的请求开销,对于大量并发的请求时,这种开销就会增加服务器的压力,可以从两方面进行优化:

  • 尽量只发送简单请求,使用GETPOST方法的同时不要去设置额外的请求头部,同时发送的Content-Type不能使用application/json,可以使用text/plain让后端解析;
  • 让服务端在响应头添加Access-Control-Max-Age参数,来缓存预检结果,避免每次请求都会先发送预检请求

JSONP

JSONP,JSON with Padding,填充式 JSON,也是跨域资源请求的一种方式。

JSONP 的原理就是利用script标签的src属性可以跨域载入资源,通过服务端返回的 JS 脚本来执行预先设置好的回调函数。

JSONP 实现的方式是:

  • 利用 DOM 接口createElement动态创建一个script标签;
  • scriptsrc属性设置成服务器的请求地址,并在后面串接一个回调函数;
  • 当服务器收到请求后,会解析请求发送的回调函数的名称,服务端在允许的情况下可以返回一串文本,形式大致就是functionName({...数据}),一个由回调函数名称和 JSON 数据组成的字符串;后端可以先获取查询数据,然后再和回调函数名称拼接字符串即可;
  • 由于是<script>元素请求的脚本,浏览器在收到服务端返回的脚本内容以后,会把它当成 JS 去执行,也就达到了获取跨域获取数据的目的
<script>
var script = document.createElement("script");
script.src = "http://www.example.com/path1?callback=handleResponse";
document.body.insertBefore(script, document.body.firstChild);
function handleResponse(jsonResponse)
{
//jsonResponse就是响应数据
}
</script>

image-20200802120029159

第三方实现

jQuery.ajax 在 1.5 版本以后,支持 jsonp 跨域请求设置,ajax 里面可以通过dataTypejsonpjsonpCallback等参数来设置 jsonp 请求,这里只能指定请求方法为GET,就算指定成 POST 方式,会自动转为 GET 方式。

https://api.jquery.com/jquery.ajax/

$.ajax({
url: '',
async: true,
method: 'GET',
dataType: 'jsonp',
jsonpCallback: 'handleResponse',
success: function(data) {
var result = JSON.stringify(data);
},
});

axios 需要安装第三方库jsonp才支持 jsonp 请求

https://github.com/axios/axios/blob/master/COOKBOOK.md#jsonp

可以自行将 jsonp 请求基于 Promise 封装为一个异步函数进行调用,参见掘金的一个回答:

https://juejin.im/post/6844903992057659400#heading-1

JSONP 的缺点

  • JSONP 通过script标签请求服务器加载文件,因此只支持GET请求,如果要携带请求参数,只能将参数串接在 URL 后面,这样直接暴露数据的方式相对来说不安全,但也并不是说绝对不安全,实际上服务端仍然可以对请求去做验证;
  • 同时 JSONP 的报错不是太明显,要确定 JSONP 是否请求失败,可以通过scriptonerror事件来处理;或者使用定时器来定时检测是否收到响应;
  • JSONP 和 CORS 都需要服务端配合,在 CORS 兼容性已经很好的今天,推荐使用 CORS。

WebSockets

WebSocket API

WebSockets,或者叫网络层套接字协议,它和 HTTP 一样,属于网络层的一种协议,并且建立在 TCP 连接基础上的,但是 WebSockets 最主要的功能是提供全双工双向通信,使得服务端可以向网页推送数据。

WebSockets 协议的原理和使用这里不做具体分析,WebSockets 协议能跨域请求的原理是使用单独的协议而不受浏览器的同源策略限制

首先要明确一点,WebSockets 需要在 HTTP 报文的基础上建立连接,在创建一个 WebSockets 对象后,浏览器就会马上尝试创建连接,过程大致如下:

首次会通过发送 HTTP 报文的方式建立与服务器的 TCP 连接,然后再发送最后一个 HTTP 报文请求服务器切换到 WebSockets 的协议上来,服务器在收到请求后会发送101响应来表示成功切换到 WebSockets 协议上了。

你可能会疑惑,为什么通过 HTTP 请求建立 TCP 连接的时候不受浏览器的同源策略限制,实际上仍然会收到限制,但是浏览器会忽略这个限制,继续发送切换到 WebSockets 协议的请求建立 WebSockets 协议的连接,而只要建立了 WebSockets 协议的连接,就不是 HTTP 协议能管的事了。

参考:https://blog.securityevaluators.com/websockets-not-bound-by-cors-does-this-mean-2e7819374acc

JS 建立 WebSockets 协议并接收数据只需两步:

  • 初始化一个 WebSocket 对象;
  • 使用onmessage方法接收数据。
// Create WebSocket connection.
const socket = new WebSocket('ws://localhost:8080');

// Connection opened
socket.addEventListener('open', function(event) {
socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', function(event) {
console.log('Message from server ', event.data);
});

反向代理

反向代理就是使用代理服务器转发请求,本地请求同源的代理服务器,然后同源服务器再将请求转发到其他服务器响应。

image-20200802155714231

webpack-dev-server就是这样的原理,可以在webpack的配置项devServer.proxy中配置代理请求:

// 现在,对 /api/users 的请求会将请求代理到 http://localhost:3000/api/users
module.exports = {
//...
devServer: {
proxy: {
'/api': 'http://localhost:3000',
},
},
};

如果是生产环境,可以考虑使用服务器自带的反向代理功能,例如nginx的反向代理,利用代理 nginx 服务器监听端口请求域名,然后根据匹配规则将请求转发到真正的目标处理服务器进行处理

server {
listen 80;
server_name 域名;
root D:\build;

location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://ip:port;
}

location /apis {
rewrite ^/apis/(.*)$ /api/v1/$1 break;
proxy_pass http://ip:port;
}

location /imgs {
rewrite ^/imgs/(.*)$ /$1 break;
proxy_pass http://ip:port;
}

location /fontimgs {
rewrite ^/fontimgs/(.*)$ /$1 break;
proxy_pass http://ip:port;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}