跨域分析以及通解

2022-05-01 11:44:09 浏览数 (1)

现今绝大多数新上线的网站都是基于前后端分离的部署模式来对外提供服务,而这种模式在不熟悉的情况下就很容易遇到一个恶心的问题——跨域

跨域形成原因

跨域是指一个域下的文档或脚本试图去请求另一个域下的资源,这里的跨域是广义的。

广义的跨域包括:

  • 资源跳转: 链接,重定向,表单提交
  • 资源嵌入: <link><script><img><iframe>DOM标签
  • 脚本请求: javascript 发起的 Ajax 请求等

而我们常说的跨域是狭义的,是由浏览器同源策略引起的一类请求场景。

同源策略

同源策略(Same Orgin Policy)是一种约定,它是浏览器核心也最基本的安全功能,它会阻止一个域的js脚本和另外一个域的内容进行交互,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。。所谓同源(即在同一个域)就是两个页面具有相同的协议(protocol)、主机(host)和端口号(port)。

我们可以这么理解一个url是由:协议、域名、端口 三部分组成。(一般端口默认80)

如:https://blog.yerikshu.lab:18822

所谓同源策略简单来说即要求同协议,同域名,同端口,三个一致。

例如:

  • A: https://blog.yerikshu.lab:8080/index.html
  • B: http://blog.yerikshu.lab:8080/index.html
  • C: https://blog.yerikshu.lab:8080/index.html
  • D: https://blog.yerikshu.lab:8081/index.html
  • E: https://blog.yerikshu.lab:8080/test.html

A与B则是不同协议,A与C不同域名,A与D不同端口这些都是跨域。注意:http://localhost:8080与http://127.0.0.1:8080也是跨域的。

由于同源策略限制的内容还包括

  1. cookie、localStorage、indexDB无法读取
  2. DOM无法获取
  3. AJAX不能发送

解决方式

jsonp绕过浏览器的同源策略,通过websocket/cors,正反代理来告诉服务端发起请求的源,由服务端来判断是否同意该请求。

常见的解决方式

  1. 最简单的方式就是将前后端都部署在同一台机器上面,系统上面解析成localhost,或许有些人会拍桌子了:本来开发初衷就是前后端分离,现在又合在一起部署,单点故障怎么办,一台机子挂了,前后端一起死。确实,但这种方式在古时候确实很方便啊,也没有所谓的跨域问题不是嘛
  2. 基于k8s进行发布,将前后端都放置在同一个service里面,通过不同的路由进行访问是不是也可以变相的认为是在同一台主机,这个其实也是一种绕过的方式,借助k8s的能力让web服务看起来是在同一台主机上面部署服务的同时具备高可用的特性

对于前端来说,可以做些什么?

  1. 一级域名相同,二级域名不同的情况下,可以设置document.domain相同,就可以共享cookie
  2. iframewindow.open方法打开的窗口为例,有三种方法可以跨域:
    1. url后#片段识别符携带传递参数 通过hashchange方法进行通知
    2. window.name不论同源只要在同一个窗口设置了这个属性就可以传参,容量大,但是需要额外监听
    3. window.postMessage 是H5引入的新的API,window.postMessage(data,target地址等)
    4. 通过上面的方法也可以读写其他窗口的localStorage

AJAX跨域

jsonp的原理就是利用<script>标签没有跨域限制,通过<script>标签src属性,发送带有callback参数的GET请求,服务端将接口返回数据拼凑到callback函数中,返回给浏览器,浏览器解析执行,从而前端拿到callback函数返回的数据。

这种方案的优点是是简单适用、支持所有的浏览器,对服务端改动非常小,缺点是只能发送get请求,而且必须设置回调函数是因为作为一个scripts标签获取的js脚本是需要被执行的,如果是纯数据的话无法执行会报错

代码语言:html复制
 <script>
    var script = document.createElement('script');
    script.type = 'text/javascript';

    // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
    script.src = 'http://www.yerik.lab:8080/login?user=yerikshu&callback=handleCallback';
    document.head.appendChild(script);

    // 回调执行函数
    function handleCallback(res) {
        alert(JSON.stringify(res));
    }
 </script>

服务端返回如下(返回时即执行全局函数):

代码语言:txt复制
handleCallback({"success": true, "user": "yerikshu"})

jquery Ajax实现

代码语言:javascript复制
$.ajax({
    url: 'http://www.domain2.com:8080/login',
    type: 'get',
    dataType: 'jsonp',  // 请求方式为jsonp
    jsonpCallback: "handleCallback",  // 自定义回调函数名
    data: {}
});

Vue axios实现

代码语言:javascript复制
this.$http = axios;
this.$http.jsonp('http://www.domain2.com:8080/login', {
    params: {},
    jsonp: 'handleCallback'
}).then((res) => {
    console.log(res); 
})

node.js实现

代码语言:javascript复制
var querystring = require('querystring');
var http = require('http');
var server = http.createServer();

server.on('request', function(req, res) {
    var params = querystring.parse(req.url.split('?')[1]);
    var fn = params.callback;

    // jsonp返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn   '('   JSON.stringify(params)   ')');

    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

webSocket是一种通信协议,不实行同源策略,在请求头中有origin属性标记了请求源,缺点是需要支持webscoket的服务器才支持。

跨域资源共享(CORS)

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器要求不能低于IE10。

CORS 整个通信过程 都是浏览器自动完成的,不需要用户进行参与,当浏览器发现XMLHTTPRequest或原生fetch请求,会自动附加一些头信息,有时会进行一次附件的预检请求。

浏览器将CORS跨域请求分为简单请求和非简单请求。

只要同时满足一下两个条件,就属于简单请求

  • 使用下列方法之一:
    • head
    • get
    • post
  • 请求的Heder是
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type: 只限于三个值:
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

不同时满足上面的两个条件,就属于非简单请求。浏览器对这两种的处理,是不一样的。

简单请求

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。

代码语言:txt复制
GET /cors HTTP/1.1
Origin: http://api.yerik.lab
Host: api.yerik.lab
Accept-Language: zh-CN
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 域名 端口)。服务器根据这个值,决定是否同意这次请求。

CORS请求设置的响应头字段,都以Access-Control-开头:

  • Access-Control-Allow-Origin:必填
    • 它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。
  • Access-Control-Allow-Credentials:可选
    • 它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。
  • Access-Control-Expose-Headers:可选
    • CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。比如,getResponseHeader('user')可以返回User字段的值。
非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUTDELETE,或者Content-Type字段的类型是application/json,亦或者是在Cookie设置特殊请求头比如X-Auth-Token这种根据业务需要自定义的参数内容

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

[非简单请求.png]
预检请求

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

  • Access-Control-Request-Method:必选
    • 用来列出浏览器的CORS请求会用到哪些HTTP方法,例子中是POST。
  • Access-Control-Request-Headers:可选
    • 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,例子中是x-goog-authuser
[预检请求.png]
代码语言:txt复制
:authority: play.yerikshu.lab
:method: OPTIONS
:path: /log?format=json&hasfast=true&authuser=0
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
access-control-request-headers: authorization,x-goog-authuser
access-control-request-method: POST
origin: https://www.play.yerikshu.lab
referer: https://www.play.yerikshu.lab/
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36 Edg/101.0.1210.32
预检请求的回应

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

HTTP回应中,除了关键的是Access-Control-Allow-Origin字段,其他CORS相关字段如下:

  • Access-Control-Allow-Methods:必选
    • 它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。
  • Access-Control-Allow-Headers
    • 如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。
  • Access-Control-Allow-Credentials:可选
    • 该字段与简单请求时的含义相同。
  • Access-Control-Max-Age:可选
    • 用来指定本次预检请求的有效期,单位为秒。

CORS跨域示例

前端设置:

原生ajax:

代码语言:javascript复制
var xhr = new XMLHttpRequest(); // IE8/9需用window.XDomainRequest兼容

// 前端设置是否带cookie
xhr.withCredentials = true;

xhr.open('post', 'http://www.play.yerik.lab/login', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('user=admin');

xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
        alert(xhr.responseText);
    }
};

jquery ajax:

代码语言:javascript复制
$.ajax({
    ...
   xhrFields: {
       withCredentials: true    // 前端设置是否带cookie
   },
   crossDomain: true,   // 会让请求头中包含跨域的额外信息,但不会含cookie
    ...
});
服务端设置:

nodejs代码

代码语言:javascript复制
var http = require('http');
var server = http.createServer();
var qs = require('querystring');

server.on('request', function(req, res) {
    var postData = '';

    // 数据块接收中
    req.addListener('data', function(chunk) {
        postData  = chunk;
    });

    // 数据接收完毕
    req.addListener('end', function() {
        postData = qs.parse(postData);

        // 跨域后台设置
        res.writeHead(200, {
            'Access-Control-Allow-Credentials': 'true',     // 后端允许发送Cookie
            'Access-Control-Allow-Origin': 'http://www.play.yerik.lab',    // 允许访问的域(协议 域名 端口)
            /* 
             * 此处设置的cookie还是domain2的而非domain1,因为后端也不能跨域写cookie(nginx反向代理可以实现),
             * 但只要domain2中写入一次cookie认证,后面的跨域接口都能从domain2中获取cookie,从而实现所有的接口都能跨域访问
             */
            'Set-Cookie': 'l=a123456;Path=/;Domain=www.play.yerik.lab;HttpOnly'  // HttpOnly的作用是让js无法读取cookie
        });

        res.write(JSON.stringify(postData));
        res.end();
    });
});

server.listen('8080');
console.log('Server is running at port 8080...');
nginx代理跨域

nginx代理跨域,实质和CORS跨域原理一样,通过配置文件设置请求响应头Access-Control-Allow-Origin…等字段。

1)nginx配置解决iconfont跨域

浏览器跨域访问js、css、img等常规静态资源被同源策略许可,但iconfont字体文件(eot|otf|ttf|woff|svg)例外,此时可在nginx的静态资源服务器中加入以下配置。

代码语言:txt复制
location / {
  add_header Access-Control-Allow-Origin *;
}
nginx反向代理接口跨域

跨域问题:同源策略仅是针对浏览器的安全策略。服务器端调用HTTP接口只是使用HTTP协议,不需要同源策略,也就不存在跨域问题。

实现思路:通过Nginx配置一个代理服务器域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域访问。

nginx具体配置:

代码语言:txt复制
#proxy服务器
server {
    listen       81;
    server_name  www.domain1.com;

    location / {
        proxy_pass   http://www.domain2.com:8080;  #反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;

        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.domain1.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}
nodejs中间件代理跨域

node中间件实现跨域代理,原理大致与nginx相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。

非vue框架的跨域

使用node express http-proxy-middleware搭建一个proxy服务器。

前端代码:

代码语言:txt复制
var xhr = new XMLHttpRequest();

// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;

// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();

中间件服务器代码:

代码语言:txt复制
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();

app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
        res.header('Access-Control-Allow-Credentials', 'true');
    },

    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
}));

app.listen(3000);
console.log('Proxy server is listen at port 3000...');
vue框架的跨域

node vue webpack webpack-dev-server搭建的项目,跨域请求接口,直接修改webpack.config.js配置。开发环境下,vue渲染服务和接口代理服务都是webpack-dev-server同一个,所以页面与代理接口之间不再跨域。

webpack.config.js部分配置:

代码语言:txt复制
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.domain2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些https服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
        }],
        noInfo: true
    }
}
document.domain iframe跨域

此方案仅限主域相同,子域不同的跨域应用场景。实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。

父窗口:(http://www.domain.com/a.html)
代码语言:html复制
<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>
子窗口:(http://child.domain.com/a.html)
代码语言:html复制
<script>
    document.domain = 'domain.com';
    // 获取父窗口中变量
    console.log('get js data from parent ---> '   window.parent.user);
</script>
location.hash iframe跨域

实现原理: a欲与b跨域相互通信,通过中间页c来实现。 三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。

具体实现:

A域:a.html -> B域:b.html -> A域:c.html,a与b不同域只能通过hash值单向通信,b与c也不同域也只能单向通信,但c与a同域,所以c可通过parent.parent访问a页面所有对象。

a.html:(http://www.domain1.com/a.html)

代码语言:html复制
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 向b.html传hash值
    setTimeout(function() {
        iframe.src = iframe.src   '#user=admin';
    }, 1000);
    
    // 开放给同域c.html的回调方法
    function onCallback(res) {
        alert('data from c.html ---> '   res);
    }
</script>

b.html:(http://www.domain2.com/b.html)

代码语言:html复制
<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 监听a.html传来的hash值,再传给c.html
    window.onhashchange = function () {
        iframe.src = iframe.src   location.hash;
    };
</script>

c.html:(http://www.domain1.com/c.html)

代码语言:html复制
<script>
    // 监听b.html传来的hash值
    window.onhashchange = function () {
        // 再通过操作同域a.html的js回调,将结果传回
        window.parent.parent.onCallback('hello: '   location.hash.replace('#user=', ''));
    };
</script>
window.name iframe跨域

window.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

a.html:(http://www.domain1.com/a.html)

代码语言:javascript复制
var proxy = function(url, callback) {
    var state = 0;
    var iframe = document.createElement('iframe');

    // 加载跨域页面
    iframe.src = url;

    // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
    iframe.onload = function() {
        if (state === 1) {
            // 第2次onload(同域proxy页)成功后,读取同域window.name中数据
            callback(iframe.contentWindow.name);
            destoryFrame();

        } else if (state === 0) {
            // 第1次onload(跨域页)成功后,切换到同域代理页面
            iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
            state = 1;
        }
    };

    document.body.appendChild(iframe);

    // 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
    function destoryFrame() {
        iframe.contentWindow.document.write('');
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
    }
};

// 请求跨域b页面数据
proxy('http://www.domain2.com/b.html', function(data){
    alert(data);
});

proxy.html:(http://www.domain1.com/proxy.html)

中间代理页,与a.html同域,内容为空即可。

b.html:(http://www.domain2.com/b.html)

代码语言:html复制
<script>
    window.name = 'This is domain2 data!';
</script>

通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

postMessage跨域

postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题:

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

用法:postMessage(data,origin)方法接受两个参数:

  • data: html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。
  • origin: 协议 主机 端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

a.html:(http://www.domain1.com/a.html)

代码语言:html复制
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>       
    var iframe = document.getElementById('iframe');
    iframe.onload = function() {
        var data = {
            name: 'aym'
        };
        // 向domain2传送跨域数据
        iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
    };

    // 接受domain2返回数据
    window.addEventListener('message', function(e) {
        alert('data from domain2 ---> '   e.data);
    }, false);
</script>

b.html:(http://www.domain2.com/b.html)

代码语言:html复制
<script>
    // 接收domain1的数据
    window.addEventListener('message', function(e) {
        alert('data from domain1 ---> '   e.data);

        var data = JSON.parse(e.data);
        if (data) {
            data.number = 16;

            // 处理后再发回domain1
            window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
        }
    }, false);
</script>
WebSocket协议跨域

WebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。

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

前端代码:

代码语言:html复制
<div>user input:<input type="text"></div>
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
var socket = io('http://www.domain2.com:8080');

// 连接成功处理
socket.on('connect', function() {
    // 监听服务端消息
    socket.on('message', function(msg) {
        console.log('data from server: ---> '   msg); 
    });

    // 监听服务端关闭
    socket.on('disconnect', function() { 
        console.log('Server socket has closed.'); 
    });
});

document.getElementsByTagName('input')[0].onblur = function() {
    socket.send(this.value);
};
</script>

Nodejs socket后台:

代码语言:javascript复制
var http = require('http');
var socket = require('socket.io');

// 启http服务
var server = http.createServer(function(req, res) {
    res.writeHead(200, {
        'Content-type': 'text/html'
    });
    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

// 监听socket连接
socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(msg) {
        client.send('hello:'   msg);
        console.log('data from client: ---> '   msg);
    });

    // 断开处理
    client.on('disconnect', function() {
        console.log('Client socket has closed.'); 
    });
});

小结

以上就是9种常见的跨域解决方案,jsonp(只支持get请求,支持老的IE浏览器)适合加载不同域名的js、css,img等静态资源;CORS(支持所有类型的HTTP请求,但浏览器IE10以下不支持)适合做ajax各种跨域请求;Nginx代理跨域和nodejs中间件跨域原理都相似,都是搭建一个服务器,直接在服务器端请求HTTP接口,这适合前后端分离的前端项目调后端接口。document.domain iframe适合主域名相同,子域名不同的跨域请求。postMessage、websocket都是HTML5新特性,兼容性不是很好,只适用于主流浏览器和IE10 。

这么多的跨域方案,没有最好,只有最合适的,根据具体的使用场景选择跨域方案。

0 人点赞