那些年我们遇到的跨域问题

关于跨域问题的三三两两。

1. 同源策略

跨域的根本原因是浏览器的同源策略,那么什么是同源策略呢?

同源策略,它是由Netscape提出的一个著名的安全策略,现在所有支持 JavaScript 的浏览器都会使用这个策略。所谓同源是指协议,域名,端口相同,相反,只要协议,域名,端口有任何一个的不同,就被当作是跨域。

浏览器采用同源策略,禁止页面加载或执行与自身来源不同的域的任何脚本。比如一个恶意网站的页面通过iframe嵌入了银行的登录页面(二者不同源),如果没有同源限制,恶意网页上的javascript脚本就可以在用户登录银行的时候获取用户名和密码。

2. 解决跨域

跨域影响:

  • Cookie、LocalStorage 和 IndexDB 无法读取
  • DOM 和 Js对象无法获得
  • AJAX 请求不能发送

1. CORS

跨域技术-CORS (CrossOrigin Resources Sharing,跨源资源共享),它是 HTML5 的一项特性,定义了一种浏览器和服务器交互的方式来确定是否允许跨域请求。

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

整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

浏览器将CORS请求分成两类:简单请求和非简单请求。

只要同时满足以下两大条件,就属于简单请求,反之,不满足以下条件的都属于复杂请求。

  1. 请求方法是以下三种方法之一:
    • HEAD
    • GET
    • POST
  2. HTTP的头信息不超出以下几种字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

1. 简单请求

对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是在头信息之中,增加一个 Origin 字段。表明该请求来自哪个源,服务器根据这个值,决定是否同意这次请求:如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段(如下)。如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现这个回应的头信息没有包含 Access-Control-Allow-Origin 字段,从而抛出错误。

1
2
3
4
5
// 同源允许,多出的几个头信息字段
Access-Control-Allow-Origin: http://www.baidu.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

2. 非简单请求

非简单请求,会在正式通信之前,增加一次 HTTP 查询请求,称为”预检”请求,也就是我们常说的 OPTIONS 请求。服务器收到”预检”请求以后,检查Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认是否允许跨源请求。如果浏览器肯定了“预检”请求,就会返回一个正常的且带有CORS相关字段的回应,反之,如果浏览器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 预检通过,允许请求的回应
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://www.baidu.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

3. CORS 简单实现

Node.js 实现 CORS 跨域请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.use(function (req, res, next) {
let {origin, Origin, referer, Referer} = req.headers;
let allowOrigin = origin || Origin || referer || Referer || '*';

res.header("Access-Control-Allow-Origin", allowOrigin);
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
res.header("Access-Control-Allow-Credentials", true);
res.header("X-Powered-By", 'Express');

if (req.method == 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
})

2. JSONP

利用 script,img 等这些元素的开放策略,即不受同源策略的影响。其工作流程如下:

  • 声明一个回调函数(cb),其函数名当做参数值,要传递给跨域请求数据的服务器,函数形参为要获取目标数据data
  • 创建一个 script 标签,把跨域的API数据接口地址,赋值给script的src,还要在这个地址中向服务器传递该函数名(可以通过问号传参:?callback=cb)
  • 服务器接收到请求后,需要进行特殊的处理:把传递进来的函数名和它需要给你的数据拼接成一个字符串,例如:传递进去的函数名是cb,它准备好的数据是cb({“name”:”aaaaaandy”})。
  • 最后服务器把准备的数据通过HTTP协议返回给客户端,同时修改返回头中’Content-Type’: ‘text/javascript’,则数据返回客户端后会马上执行之前声明的回调函数(cb),最后对返回的数据进行操作。

需要注意的是:jsonp 只能实现 GET 请求而不能实现 POST 请求。

3. nginx 代理

利用 nginx 通过反向代理 满足浏览器的同源策略实现跨域,不需要目标服务器配合

将不同的域名转换成相同的就解决了跨域的问题,客户端发送请求时不直接到服务器而是先到代理的中间层,由中间层把请求源地址改为与服务器地址相同,当数据返回时,也是先经过中间层,将数据来源地址改为与客户端地址相同。这样就不受同源策略的影响。

1
2
3
4
5
6
7
8
9
10
11
server {
listen 80;
server_name b.com;

#请求跨域,这里约定代理请求url path是以/apis/开头
location ^~/apis/ {
# 这里重写了请求,将正则匹配中的第一个()中$1的path,拼接到真正的请求后面,并用break停止后续匹配
rewrite ^/apis/(.*)$ /$1 break;
proxy_pass https://www.baidu.com/;
}
}

还可以在nginx中添加头部实现跨域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
listen 80;
server_name b.com;

# 所有请求都会经过此location
location /{
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';

if ($request_method = 'OPTIONS') {
return 204;
}
}
}

4. websocket

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

1
2
3
4
5
// 客户端
var io = io.connect('http://www.baidu.com');
io.on('data', function(data) {
console.log(data);
})
1
2
3
4
var io = require('socket.io')(server);
io.on('connection', function (client) {
client.emit('data', 'hello this is server');
})

5. postMessage

postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,可以使用它来向其它的window对象发送消息,无论这个window对象是属于同源或不同源。也就是说,它实现的是不同页面之间的通讯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1.html
<iframe id="iframe" src="http://www.a.com/1.html" style="display:none;"></iframe>
<script>
var iframe = document.getElementById('iframe');
iframe.onload = function() {
var data = {
name: 'test postmessage'
};
// 向domain b传送跨域数据
iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.b.com');
};

// 监听message,接受domain b返回数据
window.addEventListener('message', function(e) {
console.log(e.data);
}, false);
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
// 2.html
<script>
// 监听message,接收domain a的数据
window.addEventListener('message', function(e) {
console.log(e.data);

var data = JSON.parse(e.data);
if (data) {
// 向domain a发送数据
window.parent.postMessage(JSON.stringify(data), 'http://www.a.com');
}
}, false);
</script>

6. window.name

当在浏览器中打开一个页面,或者在页面中添加一个iframe时即会创建一个对应的window对象,当页面加载另一个新的页面时,window的name属性是不会变的。这样就可以利用在页面动态添加一个iframe然后src加载数据页面,在数据页面将需要的数据赋值给window.name。然而此时承载iframe的parent页面还是不能直接访问,不在同一域下iframe的name属性,这时只需要将iframe再加载一个与承载页面同域的空白页面,即可对window.name进行数据读取。

1
2
3
4
5
6
7
8
// localhost:8088/2.html
<script>
var person = {
name: 'yuan wang',
age: 21
}
window.name = JSON.stringify(person)
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// localhost:8081/1.html
<iframe src="http://localhost:8088/2.html" frameborder="1"></iframe>
<script>
var ifr = document.querySelector('iframe')
ifr.style.display = 'none'
var flag = 0;
ifr.onload = function () {
if (flag == 1) {
console.log(ifr.contentWindow.name); // 跨域获取数据
ifr.contentWindow.close();
} else if (flag == 0) {
flag = 1;
ifr.contentWindow.location = 'http://localhost:8081/proxy.html';
}
}
</script>

这里是使用iframe将2.html加载过来,因为只是为了实现跨域,所以将之隐藏,但是,这时已经完成了最重要的一步,就是将iframe中window.name已经成功设置,但是现在还获取不了,因为是跨域的,所以,我们可以把src设置为当前域的proxy.html。

1
2
3
4
5
6
7
8
9
10
11
// proxy.html 其实就是一个空白页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>proxy</title>
</head>
<body>
<p>proxy页面</p>
</body>
</html>

7. document.domain

这种方式只适合主域名相同,但子域名不同的iframe跨域。比如主域名是http://crossdomain.com:9099,子域名是http://child.crossdomain.com:9099,这种情况下给两个页面指定一下document.domain即document.domain = crossdomain.com就可以访问各自的window对象了。