浅谈SOP同源策略

web安全的基石就是同源政策。

何为同源

几个页面的协议、主机名、端口相同,那么就认为这些页面是同源的。
如与 http://book.nonuplebroken.com:80/index.php 比较:

对于about:blankjavascript:这种特殊的 URL,他们的源应当是继承自加载他们的页面的源。

何为同源策略

同源策略(Same Origin Policy,SOP)是Web应用程序的一种安全模型,主要是限制了页面从另一个源加载资源时的行为。而且既然只是一种模型,那么不同组织企业实现时就会有不少差异。

同源策略只作用在实现了同源策略的WEB客户端上。 现在网上对同源策略有一个错误的解释:只有和目标同源的脚本才会被执行。这是不对的,同源策略没有禁止脚本的执行,而是禁止读取HTTP回复。因此会发现,同源策略的作用其实很有限,如防止CSRF攻击。

限制范围

如果非同源,共有三种行为受到限制:

  1. Cookie、LocalStorage 和 IndexDB 无法读取。
  2. DOM 无法获得。
  3. AJAX 请求不能发送。

同源策略对于防范恶意页面是一种很好的防御机制,如果恶意脚本请求了非同源的一个资源,那么这种行为就很可能因为同源策略的限制被浏览器拒绝回复,从而在某种程度上缓解了攻击。虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。因此有一些方法规避上面三种限制。

跨源的网络访问

现在知道了浏览器会根据同源策略允许或拒绝加载某些资源。可是想一想实际中的网页,就发现了问题:网站通常会将静态文件(css、js、图片)等放置在 CDN 上,那么 CDN 与当前域必然是不同源的。那么这是怎么回事呢?这就是跨源的网络访问。

页面跨域的行为主要会分为三类,分别是:

  • Cross-origin write,跨域写。通常被允许,例如链接,重定向和表单提交,一些不常见的HTTP请求方法例如PUT、DELETE等需要先发送预请求(preflight),例如发送OPTIONS来查询可用的方法。

  • Cross-origin embedding,跨域嵌入,通常被允许。

  • Cross-origin read,跨域读。通常被禁止,然而,我们可以用其他方法达到读取的效果。

一些可以跨域的方法:

具备src的标签

所有具有src属性的HTML标签都是可以跨域的,如<script><img><iframe><link>这几个标签是可以加载跨域的资源的,并且加载的方式其实相当于一次普通的GET请求,唯一不同的是,为了安全起见,浏览器不允许这种方式下对加载到的资源的读写操作,而只能使用标签本身应当具备的能力(比如脚本执行、样式应用等等)。

1
2
3
4
5
6
7
8
9
10
11
# img标签跨域获取了一个图片资源,并且正常显示:
<img src="http://img0.imgtn.bdimg.com/it/u=1619683067,2993665289&fm=26&gp=0.jpg">

# script标签跨域获取了一个js脚本,也可以正常执行:
<script src="http://119.23.37.178:8000/myjs/alert.js"></script>

# 虽然可以获取到资源,但无法执行
<img src="http://119.23.37.178:8000/myjs/alert.js">

# link标签同样也可以跨域
<link rel="stylesheet" href="http://119.23.37.178:8000/myjs/alert.js">

JSONP

JSONP跨域是一个非官方的协议。由于<script>标签是可以跨域的,而且在跨域脚本中可以直接回调当前脚本的函数,通过预先设定好的callback函数来实现和母页面的交互,通过JSON来传参,即将JSON数据填充进回调函数,这就是JSONP的 JSON with Padding 的含义。JSONP只支持GET请求。

例如,前端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type", "text/javascript");
script.src = src;
document.body.appendChild(script);
}

window.onload = function () {
addScriptTag('http://66.42.62.66:8080/?callback=work');
}

function work(data) {
console.log('Your random int is: ' + data.int);
};
</script>

上面代码通过动态添加<script>标签,向服务器发出请求。服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。

后端:

1
2
3
4
5
<?php
$callback = $_GET['callback']; // 得到回调函数名
$data = array('int' => mt_rand(0, 10000)); // 要返回的数据
echo $callback. '('. json_encode($data). ')'; // 输出
?>

由于<script>标签请求的脚本,直接作为代码运行。这时,只要浏览器定义了work函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象。

跨域资源共享

跨域资源共享(Cross Origin Resource Share,CORS),允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。CORS需要服务器端设置Access-Control-Allow-Origin头,否则浏览器会因为安全策略拦截返回的信息。

CORS又分为简单跨域和非简单跨域请求。

简单请求

简单请求满足以下两个条件:

  • 请求方法是以下三种方法之一:

    • HEAD
    • GET
    • POST
  • HTTP的头信息不超出以下几种字段:

    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type:只限于三个值:application/x-www-form-urlencoded、multipart/form-data、text/plain

其他的都是非简单请求。

例如:

1
2
3
4
5
6
7
8
9
10
<script>
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://66.42.62.66:8080/ajax.php?a=123456", true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.status == 200 && xhr.readyState == 4) {
          alert(xhr.responseText);
}
}
</script>

当浏览器判定此次请求是简单请求,会在请求头中增加一个Origin字段:

1
Origin: http://www.test.com

服务器根据Origin判断是否允许此次请求,如果允许会返回响应头:

1
2
3
Access-Control-Allow-Origin: http://www.test.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: flag

其中:

  1. Access-Control-Allow-Origin
    该字段必须。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

  2. Access-Control-Allow-Credentials
    该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。若要发送Cookie,AJAX中必须设置xhr.withCredentials = true;,而且Access-Control-Allow-Origin不能为星号。Cookie依然遵循同源策略。

  3. Access-Control-Expose-Headers
    该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。

如果服务器会不允许,则返回一个正常的HTTP回应。浏览器发现就知道错了,抛出一个错误。

非简单请求

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

例如:

1
2
3
4
5
6
<script>
var xhr = new XMLHttpRequest();
xhr.open("PUT", "http://66.42.62.66:8080/ajax.php", true);
xhr.setRequestHeader('X-Admin', '1');
xhr.send();
</script>

浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,请求头:

1
2
3
Origin: http://www.test.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Admin

服务器收到”预检”请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,允许跨源请求,则响应头:

1
2
3
4
5
Access-Control-Allow-Origin: http://www.test.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Admin, X-Guest
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 172800

其中Access-Control-Allow-Methods和Access-Control-Allow-Headers不限于浏览器在”预检”中请求的字段,表明服务器支持的所有跨域请求的方法、方法。

如果服务器会不允许,则返回一个正常的HTTP回应。浏览器就知道错了,抛出一个错误。

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样。

window.name

对当前窗口的window.name赋值,没有特殊的字符限制。由于window对象是浏览器的窗体,而非document对象,因此很多时候window对象不受同源策略的影响。可以利用它,实现跨域、跨页面传递数据。

例如:
www.aaa.com/index.html 的代码为:

1
2
3
4
5
<script>
window.name = "I have a apple.";
alert(document.domain + ", " + window.name);
window.location = "http://www.bbb.com"
</script>

www.bbb.com/index.html 的代码为:

1
2
3
<script>
alert(document.domain + ", " + window.name);
</script>

3

可以看到数据已经通过window.name跨域了。

document.domain

相同主域名不同子域名下的页面,可以设置document.domain让它们同域。

a.test.com:

1
2
3
4
5
6
7
<iframe id = "iframe" src="http://b.test.com"></iframe>
<script>
window.onload = function() {
document.domain = 'test.com';
alert(document.cookie);
}
</script>

b.test.com:

1
2
3
4
<script>
document.domain = 'test.com';
document.cookie = "secret=apple";
</script>

location.hash

location.hash属性是一个可读可写的字符串,该字符串是 URL 的锚部分(从 # 号开始的部分)。

利用location.hash来进行传值,父窗口可以把信息写入子窗口,子窗口也可以写入父窗口。

例如,www.aaa.com 下的a.php想和 www.bbb.com 下的b.php通信,但是由于同源策略的限制他们无法进行交流(b.php无法返回数据),于是就需要一个中间人:www.aaa.com 下的proxy.php。b.php将数据传给proxy.php,由于proxy.php和a.php同源,于是可通过proxy.php将返回的数据传回给a.php,从而达到跨域的效果。

www.aaa.com 下的a.php:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
window.onload = function() {
var ifr = document.createElement('iframe');
ifr.src = 'http://www.bbb.com/b.php'+location.hash;
document.body.appendChild(ifr);
};

window.onhashchange = function() {
var data = location.hash ? location.hash.substring(1) : '';
console.log('Now the hash is ' + data);
}
</script>

www.bbb.com 下的b.php:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
function checkHash(){
var data = Number(location.hash.substring(1))**2 + 1;
callBack(data);
}
function callBack(hash){
var proxy = document.createElement('iframe');
proxy.src = 'http://www.aaa.com/proxy.php#' + hash;
document.body.appendChild(proxy);
}
window.onload = checkHash;
</script>

www.aaa.com 下的 proxy.php

1
2
3
4
5
6
<script>
function set() {
parent.parent.location.hash = location.hash;
}
setTimeout(set, 1000);
</script>

打开 http://www.aaa.com#100 ,就会返回100**2+1 = 10001。

4

window.postMessage

HTML5新增的postMessage方法,通过postMessage来传递信息,对方可以通过监听message事件来监听信息。可跨主域名及双向跨域。

postMessage的使用方法: otherWindow.postMessage(message, targetOrigin)

  • otherWindow:目标窗口,给哪个window发消息,是 window.frames 属性的成员或者由 window.open 方法创建的窗口
  • message:是要发送的消息,类型为 String、Object (IE8、9 不支持)
  • targetOrigin:是限定消息接收范围,不限制使用 *

例如:
www.aaa.com:

1
2
3
4
5
6
7
8
<iframe id="bbb" src="http://www.bbb.com" onload="postMsg()"></iframe>
<script>
function postMsg (){
var iframe = document.getElementById('bbb');
var bbb = iframe.contentWindow;
bbb.postMessage("Hello!", 'http://www.bbb.com');
}
</script>

www.bbb.com:

1
2
3
4
5
6
<script>
window.onmessage = function(e) {
if (e.origin !== 'http://www.aaa.com') return;
alert(e.data + " from " + e.origin);
}
</script>

成功接收到消息:

5

参考