同源政策及跨域解决方案简析

1,316 阅读13分钟

浏览器安全的基石是“同源政策”(same-origin policy)。

概述

1. 含义

1995年,同源政策由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个策略。

最初,它的含义是指,A 网页设置的 Cookie,B 网页不能打开,除非这两个网页“同源”。所谓“同源”指的是“三个相同”:

  • 协议相同
  • 域名相同
  • 端口相同

举例来说,http://www.example.com/dir/page.html这个网址,协议是http://,域名是www.example.com,端口是80(默认端口可以省略),它的同源情况如下:

  • http://www.example.com/dir2/other.html:同源
  • http://example.com/dir/other.html:不同源(域名不同)
  • http://v2.www.example.com/dir/other.html:不同源(域名不同)
  • http://www.example.com:81/dir/other.html:不同源(端口不同)
  • https://www.example.com/dir/page.html:不同源(协议不同)

2. 目的

同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。

设想这样一种情况:A 网站是一家银行,用户登录以后,A 网站在用户的机器上设置了一个 Cookie,包含了一些隐私信息(比如存款总额)。用户离开 A 网站以后,又去访问 B 网站,如果没有同源限制,B 网站可以读取 A 网站的 Cookie,那么隐私信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。

由此可见,同源政策是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。

3. 限制范围

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

  • 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB。
  • 无法接触非同源网页的 DOM。
  • 无法向非同源地址发送 AJAX 请求(可以发送,但浏览器会拒绝接受响应)。

另外,通过 JavaScript 脚本可以拿到其他窗口的window对象。但是,如果是非同源的网页,目前允许一个窗口可以接触其他网页的window对象的九个属性和四个方法

  • window.closed
  • window.frames
  • window.length
  • window.location
  • window.opener
  • window.parent
  • window.self
  • window.top
  • window.window
  • window.blur()
  • window.close()
  • window.focus()
  • window.postMessage()

上面的九个属性之中,只有window.location是可读写的,其他八个全部都是只读。而且,即使是location对象,非同源的情况下,也只允许调用location.replace方法和写入location.href属性。

window.opener的同源问题点击此处

虽然这些限制是必要的,但是有时很不方便,下面介绍如何规避上面的限制。

Cookie

如果两个网页一级域名相同,只是次级域名不同,浏览器允许通过设置document.domain共享 Cookie。

举例来说,A 网页的网址是http://w1.example.com/a.html,B 网页的网址是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享 Cookie。因为浏览器通过document.domain属性来检查是否同源。

document.domain = 'example.com';

注意,这里两个网页都需要设置。因为设置document.domain的同时,会把端口重置为null,因此如果只设置一个网页的document.domain,会导致两个网址的端口不同,还是达不到同源的目的。

另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,比如.example.com

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

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

iframe 和多窗口通信

当使用iframe元素在当前网页中嵌入其他网页,或者使用window.open方法打开的窗口,只要不同源形成跨域,父窗口与子窗口之间就无法通信。

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

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

  • 片段识别符(fragment identifier)
  • 跨文档通信API(Cross-document messaging)

1. 片段识别符(fragment identifier)

片段标识符指的是,URL 的#号后面的部分,比如http://example.com/x.html#fragment#fragment。如果只是改变片段标识符,页面不会重新刷新。

在父窗口中,可以把信息写入子窗口的片段标识符中。

var src = originURL + '#' + data;
document.getElementById('childIFrame').src = src;

在子窗口中,通过hashchange事件监听片段识别符的变化。

window.onhashchange = checkMessage;

function checkMessage() {
  var message = window.location.hash;
  // ...
}

反之,子窗口给父窗口传递信息,也可以按照这个方法,子窗口改变父窗口的片段识别符,父窗口进行监听。

2. 跨文档通信 API(Cross-document messaging)

HTML5 为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个 API 为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。

3. LocalStorage

我们可以通过window.postMessage方法,将当前窗口的 LocalStorage 传给其他窗口。

AJAX

同源政策规定,AJAX 请求只能发给同源的网址,否则就报错。

除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制:

  • JSONP
  • WebSocket
  • CORS

1. JSONP

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

它的基本思想是,网页通过添加一个<script>元素,向服务器请求 JSON 数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。

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

function addScriptTag(src) {
    var script = document.createElement('script');
    script.setAttribute("type","text/javascript");
    script.src = src;
    document.body.appendChild(script);
}

addScriptTag('http://example.com/ip?callback=foo');

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

上面代码通过动态添加<script>元素,向服务器example.com发出请求。注意,该请求的查询字符串有一个callback参数,用来指定回调函数的名字,这对于 JSONP 是必需的。

服务器收到这个请求以后,会将数据放在回调函数的参数位置返回:

foo({
  "ip": "127.0.0.1"
});

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

2. WebSocket

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

下面是一个例子,浏览器发出的 WebSocket 请求的头信息:

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 才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

3. CORS

CORS 是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是 W3C 标准,属于跨源 AJAX 请求的根本解决方法。相比 JSONP 只能发GET请求,CORS 允许任何类型的请求。JSONP 的优势在于支持老式浏览器,以及可以向不支持 CORS 的网站请求数据。

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

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与普通的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨域,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感知。因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨域通信

3.1 两种请求

CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求:

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

凡是不同时满足上面两个条件,就属于非简单请求。

一句话,简单请求就是简单的 HTTP 方法与简单的 HTTP 头信息的结合。

这样划分的原因是,表单在历史上一直可以跨域发出请求。简单请求就是表单请求,浏览器沿袭了传统的处理方式,不把行为复杂化,否则开发者可能转而使用表单,规避 CORS 的限制。对于非简单请求,浏览器会采用新的处理方式。

3.2 简单请求

3.2.1 基本流程

对于简单请求,浏览器直接发出 CORS 请求,并且在头信息之中,增加一个Origin字段:

GET /cors HTTP/1.1
Origin: https://wangdoc.com

在头信息中,Origin字段表明本次请求来自哪个域(协议+域名+端口)。服务器会根据这个值,判断是否同意这个请求。

Origin字段的域不在许可范围内,服务器会返回一个 HTTP 回应。该回应中不包含Access-Control-Allow-Origin字段,浏览器会抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP 回应的状态码有可能是200

Origin字段的域在许可范围内,服务器返回的响应中会包含三个与CORS请求相关的头信息字段。

Access-Control-Allow-Origin: https://wangdoc.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
  • Access-Control-Allow-Origin (必须)

    它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

  • Access-Control-Allow-Credentials

    它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为true,即表示服务器明确许可,浏览器可以把 Cookie 包含在请求中,一起发给服务器。
    注:这个值也只能设为true,如果服务器不要浏览器发送 Cookie,不发送该字段即可。

  • Access-Control-Expose-Headers

    在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个最基本的响应头(Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma),如果要访问其他头,则需要服务器设置本响应头。若有多个,则通过,隔开。
    在上面的例子中,通过getResponseHeader访问X-My-Custom-HeaderX-Another-Custom-Header响应头了。

3.2.2 withCredentials 属性

CORS 请求默认不包含 Cookie 信息(以及 HTTP 认证信息等),这是为了降低 CSRF 攻击的风险。但是某些场合,服务器可能需要拿到 Cookie,这时需要服务器显式指定Access-Control-Allow-Credentials字段为true

同时,开发者必须在 AJAX 请求中将withCredentials属性打开。

var xhr = new XMLHttpRequest();
// 显示打开
xhr.withCredentials = true;

// 显示关闭
xhr.withCredentials = false;

否则,即使服务器要求发送 Cookie,浏览器也不会处理。

如果服务器要求浏览器发送 Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源政策。

3.3 非简单请求

3.3.1 预检请求

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

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

可通过设置 HTTP 头信息(具体见下文),避免在一定时间内,浏览器重复发起大量的不被支持的跨域请求。

下面是一段非简单请求。

var url = 'https://wangdoc.com/apis';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

浏览器发现,这是一个非简单请求,就自动发出一个“预检”请求,要求服务器确认可以这样请求。下面是这个“预检”请求的 HTTP 头部分信息。

OPTIONS /apis HTTP/1.1
Origin: https://wangdoc.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

“预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。

除了关键字段Origin,“预检”请求的头信息包括两个特殊字段。

  • Access-Control-Request-Method (必须)

    用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是PUT

  • Access-Control-Request-Headers

    该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上例是X-Custom-Header

3.3.2 预检请求的回应

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

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: https://wangdoc.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

如果服务器否定了“预检”请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段,或者明确表示请求不符合条件。

OPTIONS https://wangdoc.com HTTP/1.1
Status: 200
Access-Control-Allow-Origin: https://notyourdomain.com
Access-Control-Allow-Method: POST

该回应信息中,Access-Control-Allow-Origin字段明确不包括发出请求的https://wangdoc.com。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。

XMLHttpRequest cannot load http://api.alice.com.
Origin https://wangdoc.com is not allowed by 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

    用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

3.3.3 浏览器的正常请求和回应

一旦服务器通过了“预检”请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

参考链接