网络基础18 跨域知识总结

跨域知识总结。

跨域及产生原因

什么是跨域

跨域是指从一个域名的网页去请求另一个域名的资源。比如从www.baidu.com页面去请求www.google.com的资源。

跨域的严格一点的定义是:协议(http&https)、端口(80&81)、域名(baidu&google)、二级域名(news&sports)不相同,都为跨域。

受到跨域的限制,浏览器不能用AJAX获取不同源的数据,也不能在同一个页面但是处于不同域的框架之间的进行数据传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
URL                                      说明                    是否允许通信

http://www.domain.com/a.js 同一域名,不同文件或路径 允许
http://www.domain.com/b.js
http://www.domain.com/lab/c.js

http://www.domain.com:8000/a.js 同一域名,不同端口 不允许
http://www.domain.com/b.js

http://www.domain.com/a.js 同一域名,不同协议 不允许
https://www.domain.com/b.js

http://www.domain.com/a.js 域名和域名对应相同ip 不允许
http://192.168.4.12/b.js

http://www.domain.com/a.js 主域相同,子域不同 不允许
http://x.domain.com/b.js
http://domain.com/c.js

http://www.domain1.com/a.js 不同域名 不允许
http://www.domain2.com/b.js

为什么浏览器要限制跨域访问

==首先要注意,跨域是浏览器的限制,服务端不存在跨域的问题~==

原因就是安全问题:如果一个网页可以随意地访问另外一个网站的资源,那么就有可能在客户完全不知情的情况下出现安全问题。比如下面的操作就有安全问题:

用户访问www.mybank.com,登陆并进行网银操作,这时浏览器会生成对应的cookie。用户突然想起件事,并迷迷糊糊地访问了一个邪恶的网站www.xiee.com,这时该网站就可以在它的页面中,拿到银行的cookie,cookie中可能存放着很多敏感信息比如用户名,登陆token等,然后发起对www.mybank.com的操作。如果这时浏览器不予限制,并且银行也没有做响应的安全处理的话,那么用户的信息有可能就这么泄露了。

为什么要跨域

有时公司内部有多个不同的子域,比如一个是location.company.com, 而应用是放在app.company.com, 这时想从app.company.com去访问location.company.com的资源就属于跨域。

不受跨域限制的情况

有两种情况是不受浏览器同源策略的限制的,第一种是HTML标签中像<srcipt><img>等获取资源的标签是没有跨域限制的,第二种是提交表单是不受跨域的限制的。

要注意的是,表单提交只有通过指定表单的action这种形式才是不受跨域限制的,而通过Ajax手动控制表单提交的情况还是会收到跨域的限制的。

这是因为通过action提交表单,剩余的操作就交给了action里面的域,本页面的逻辑和这个表单没关系,不需要表单里的域的吓响应,所以浏览器认为是安全的。

而使用Ajax来发送表单的请求的时候,页面JS会需要知道请求的返回值,这个时候表单的情况会收到跨域的限制。

跨域限制的是哪个步骤

当我们尝试发送一个跨域请求的时候,会被浏览器阻止,在控制台收到错误信息:

我们是收不到对方服务器的返回结果的,但是浏览器阻止的究竟是请求的发出还是响应的读取呢?

首先使用Koa2搭建一个HTTP服务,运行在8080端口:

1
2
3
4
5
6
7
8
9
10
11
12
const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
console.log('收到跨域请求', ctx.request.query.test);
ctx.body = {val: 'node'};
ctx.cookies.set('userId', '123');
});

app.listen(8080, () => {
console.log('app is listening 8080...');
});

前端页面运行在http://localhost:63342,当页面访问http://localhost:8080进行跨域请求接口数据的时候,浏览器由于跨域的原因报错,没有收到服务器响应的数据,并且cookie也没有写入成功。

但是服务端的控制台会显示这次请求的信息:

证明,浏览器的同源策略,并没有限制请求的发送,而是在浏览器接受响应数据前进行了检查和限制。

为什么这样限制呢?因为有可能服务端进行了允许特定网站的跨域请求,浏览器如果从发送请求就限制住,那么就不存在任何跨域的可能性了,所以需要根据服务端返回的结果在进行检查,是要限制还是将数据解析展示。

跨域的方法

跨域类型我个人觉得可以分为两种,一种是接口跨域,也就是需要跨域去请求接口,返回服务端的数据,是页面与服务端的通信;另外一种是页面跨域,是两个页面间的通信,可能是两个Tab间的通信,也可能是主页面与iframe之间的通信。

接口跨域的方法:

  1. 动态创建script
  2. JSONP
  3. CORS
  4. Nginx反向代理跨域
  5. 利用后端转发请求跨域
  6. <img>标签
  7. Websocket
  8. SSE + EventSource

页面跨域的方法:

  1. document.domain
  2. window.name
  3. postMessage

接口跨域

(1)动态创建script

由于HTML标签中像<srcipt><img>等获取资源的标签是没有跨域限制的,利用这一点就可以实现跨域。

在本地定义一个方法cb,在一个<srcipt>标签中的src访问跨域的目标地址,并通过查询参数将函数名传递给服务端。服务端在接受到请求后将数据作为cb的参数,返回序列化的cb(params)

由于请求是由<script>标签发出的,所以接受到服务端的结果后就会立即调用cb,并且参数就会返回的数据。

前端代码:

1
2
3
4
5
6
7
<script type="text/javascript">
var localHandler = function(data) {
console.log('我是本地函数,可以被跨域的remote.js文件调用,远程js带来的数据是:' + data.result);
};
</script>
<!-- 这个标签可以通过 document.createElement 动态创建 -->
<script type="text/javascript" src="http://remoteserver.com/remote.js"></script>

服务端代码(使用Node):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var http = require('http');
var urllib = require('url');

var port = 8081;
var data = {'name': 'jifeng', 'company': 'taobao'};

http.createServer(function(req, res){
// 解析参数
var params = urllib.parse(req.url, true);

if (params.query && params.query.callback) {
// JSONP
var str = params.query.callback + '(' + JSON.stringify(data) + ')';
res.end(str);
} else {
res.end(JSON.stringify(data));
}
}).listen(port, function(){
console.log('server is listening on port ' + port);
})

这种方式比较常用,只要服务端部署了响应的代码,前端按照约定传递一个callback的查询参数即可,但是要注意的就是对XSS的防范,防止攻击者构造特殊的callback代码,实现注入攻击。

可以将创建<script>标签的过程封装起来

1
2
3
4
5
6
function getJSON (url, callBack) {
const src = `${url}?callback=${callBack}`;
const script = document.createElement('script');
script.src = src;
document.body.appendChild(script);
}

(2)JSONP

JSONP就是对上面的方法的一个封装,一种是get/getJSON的方式,另一种是$.ajax()的方式,都支持GET请求,不支持POST请求。

前端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//getJOSN方法
$.getJSON("http://localhost:8080/msg/front/jcj/t1.jsp?callback=?", function(result) {});

//ajax方法
$.ajax({
type: "get",
url: "http://localhost:8080/msg/front/jcj/t1.jsp",
//也可以直接将callback写在url中
//url: "http://localhost:8080/msg/front/jcj/t1.jsp?callback=m1",
dataType: "jsonp",
jsonpCallback: "m1",
success: function(data) {
//处理返回的数据
}
});

function m1(data) {
alert(data);
}

第一种方式的callback=?回调函数标志着Ajax请求是以JSONP的方式发送请求,客户端请求会自动在问号处增加一个方法名,方法名是以jquery开头的一串数字

而第二种方式的是参数dataType="jsonp"来说明是以JSONP的方式发送请求,所以可以直接在callback后面直接指定方法名callback=m1,而不需要jsonpCallback: "m1"这一行代码。

(3)CORS(跨域资源共享)

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing),这种跨域方式如果不携带cookie跨域,那么前端发送请求不需要额外处理,如果需要携带cookie,那么需要添加withCredential: true这个字段

1
2
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

主要的工作量都在后端代码,来设置响应头,如果是简单请求,那么只需要设置Access-Control-Allow-Origin即可,如果是非简单请求那么需要同时设置Access-Control-Allow-OriginAccess-Control-Request-MethodAccess-Control-Allow-Headers,如果需要跨域那么还需要额外设置Access-Control-Allow-Credentials

一般都会使用专门的中间件来帮助我们完成,比如koa-cors

(4)Nginx反向代理跨域

以前其实并不了解Nginx跨域的原理,其实就是利用了服务度不存在跨域的原理,前端JS访问自己本地的资源,而Nginx将访问跨域目标的请求进行了拦截,将请求转发到真实的地址,获取到数据。

上面的的方法都有一个共同的限制,那就是必须在要跨域的目标服务器上进行相应的部署,但是有一种最常遇到的情形,就是想要获取的数据的网站是不受自己控制的,开发者只能控制一个域。

这时候可以通过Nginx进行反向代理跨域,具体的配置可以参考这篇笔记《零散专题33 Nginx

(5)利用后端转发请求跨域

除了使用Nginx,也可以使用其他的框架来进行本地请求的转发,比如如果使用Node的Express框架编写的服务端程序,那可以使用request模块进行请求转发,实现跨域请求。

1
2
3
4
5
6
var request = require('request');

app.use('/api', function(req, res) {
var url = apiUrl + req.url;
req.pipe(request(url)).pipe(res);
});

上面完整的请求传递给API,并且将相应传递给请求的发起者,支持GET/PUT/DELETE的其他请求方式

使用的时候,前端只需将需要跨域的请求请求到/api地址,将真实地址放到查询参数中:

1
2
3
4
export const QUERY_URL = 'https://wallstreetcn.com/live/global';
const X_URL = `/api/proxy?url=${encodeURIComponent(QUERY_URL)}`;

fetch(X_URL).then(v => console.log(v.text()))

(6)<img>标签

使用<img>标签原理与使用<srcipt>的原理是相同的,<img>标签不存在跨域的问题,可以访问任意其他网页的图片,并且可以通过onloadonerror事件了解到响应是何时完成的

1
2
3
4
5
6
7
let img = new Image();

img.onload = img.onerror = function () {
alert('done')
}

img.src = 'http://www.baidu.com/test?name=123'

imgsrc被设置那一刻,请求就发出了,服务器的响应一般是像素图或204 No-Content

一般用来跟踪用户点击页面或动态广告曝光字数,是一种==单向的跨域方式==,==只能是GET请求==,并且无法访问服务器的相应文本。

(7)Websocket

Websocket建立的连接不存在跨域问题,因此可以通过建立Websocket打开到任何站点的俩进阶,至于是否可以与页面通信,完全取决于服务器(通过握手信息就可以知道请求来自何方)

前端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<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>

服务端Node代码:

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
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.');
});
});

(8)SSE + EventSource

SSE(server-sent events)是服务器发送事件,是一种服务器向客户端推送的单向通信技术。它的API就是EventSource接口

URL可以与当前网址同域,也可以跨域。跨域时,可以指定第二个参数,打开withCredentials属性,表示是否一起发送Cookie。

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
if (window.EventSource) {
// 参数是一个URL,可以使与当前网址同域,也可以跨域
// 打开withCredentials属性,表示是否一起发送Cookie。
const source = new EventSource('myEvent.com', {
withCredentials: true
});

// EventSource实例的readyState属性,表明连接的当前状态
if (source.readyState === 0) {
// 0 相当于常量EventSource.CONNECTING,表示连接还未建立,或者断线正在重连。
} else if (source.readyState === 1) {
// 1 相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
} else if (source.readyState === 2) {
// 2 相当于常量EventSource.CLOSED,表示连接已断,且不会重连。
}

// 连接一旦建立,就会触发open事件,可以在onopen属性定义回调函数。
source.addEventListener('open', function(event) {
// ...
}, false);

// 客户端收到服务器发来的数据,就会触发message事件,可以在onmessage属性的回调函数。
source.addEventListener('message', function(event) {
var data = event.data;
// handle message
}, false);

// 如果发生通信错误(比如连接中断),就会触发error事件,可以在onerror属性定义回调函数。
source.addEventListener('error', function(event) {
// handle error event
}, false);

// close方法用于关闭 SSE 连接。
source.close();
}

页面跨域

(1)document.domain + iframe

适用于主域相同,子域不同的情况,并且需要对跨域的两个网页的JS脚本都进行修改。例如在http://www.a.com/a.htmlhttp://script.a.com/b.html之间的跨域就可以使用这种方法。

主域名是不带www的域名,比如a.com, 主域名带前缀的通常是二级域名或者多级域名,比如www.a.com

首先在a.html中添加下面的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// a.html
// 设置共同的 domain
document.domain = "a.com";

var ifr = document.createElement("iframe");
ifr.src = "ttp://script.a.com/b.html";
ifr.style.display = "none";
document.body.appendChild(irf);

ifr.onload = function() {
//在这里操作b.html的contentWindow
var contentWindow = ifr.contentDocument;
console.log(contentWindow.message); // hello

// 还可以获取 document
var doc = ifr.contentDocument || contentWindow.document;
}

b.html通过iframe添加到a.html页面下,也需要在b.html中设置相同的domain

1
2
3
4
5
6
// b.html
// 设置共同的 domain
document.domain = "a.com";

// 添加数据
window.message = 'hello'

这样就将两个原本跨域的网页的域统一了,此时就和平时同一个域镶嵌iframe一样,通过iframe的contentDocument中就可以实现数据交互。

页面默认的domainwindow.loaction.hostnamedocument.domain只能设置成自身或更高一级的父域,比如a.b.example.com中的某个页面的document.domain可以设置为a.b.example.comb.example.comexample.com,但是不能是设置为c.a.b.example.combaidu.com

因此为保证两个主域相同,子域不同的页面跨域,二者的document.domain只能设置为二者的共同的主域,即a.com

这种方法依赖于iframe,iframe的缺点也是一堆,现在的大部分网站避免使用iframe。

历史上,iframe 常被用于复用部分界面,但是多数情况下并不合适。现在,应该使用iframe的例子如:

  1. 沙箱隔离。
  2. 引用第三方内容。
  3. 独立的带有交互的内容,比如幻灯片。
  4. 需要保持独立焦点和历史管理的子窗口,如复杂的Web应用。

缺点也很明显:大量使用,打开一个网页加载过多iframe体验很不友好而且影响网页加载速度,对爬虫不够友好。

(2) window.name

将JSON格式的数据写入到window.name中,通过共享一个Tab窗口的两个页面的共同的window.name实现跨域的数据传递

window.name属性在一个窗口(window)生命周期内,窗口载入的所有的页面都是共享一个window.name的,每个页面对window.name都有读写权限。

window.name是持久存在一个窗口载入过的所有页面中的,并不会因新页面的载入而重置。window.name只能是字符串,最大允许2M左右数据。

这种方法的限制太多,不展开了。

(3)postMessage

页面和其打开的新窗口的数据传递、页面与嵌套的iframe消息传递都可以使用postMessage方法跨域或同源传递数据。

postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。

1
window.postMessage(message, targetOrigin);
  • message是要发送的数据,可以是JavaScript的任意基本类型或可复制的对象,但是出于兼容性考虑,最好使用JSON.stringify()方法对对象参数序列化。
  • targetOrigin用来限制otherWindow对象所在的域,指明目标窗口的URL,可以将参数设置为”*“传递给任意窗口,如果要指定和当前窗口同源的话设置为”/“。

接受消息的页面通过通过监听windowmessage事件获取传过来的消息,消息内容存储在message事件对象的data属性中。

1
2
3
window.addEventListener("message", function(e){
console.log(e.data)
}

(4)代码示例

a.com上的a.html的代码:

1
2
3
4
5
6
7
8
9
10
11
12
<iframe id="ifr" src="b.com/b.html"></iframe>
<!--对接受信息页面的引用-->
<script type="text/javascript">
window.onload = function() {
var ifr = document.getElementById("ifr").contentWindow;
//获取框架的window对象
var targetOrigin = "http://b.com" //对目标的限定
setTimeout(() => {
ifr.postMessage("i love you", targetOrigin);
})
}
</script>

b.com上的b.html的代码:

1
2
3
4
5
6
7
8
9
<script type="text/javascript">
window.addEventListener("message", function(event) { //注册message事件用来接收消息
if (event.origin === "http://a.com") { //通过origin判断消息来源地址
alert(event.data); //通过data属性获取"i love you"
alert(event.source); //对a.com/a.html中window对象的引用
alert(event.origin); //发送消息窗口的源(协议+主机+端口号)
}
}, false)
</script>

参考