何为跨域?如何跨域?

(任性决定以后英文一个月中文一个月交替!:smile:)

何为跨域

Web 站点众多,人心难测。当在访问未知站点时,我们无法确定其是否可信。如果该站点试图读取(譬如通过 ajax)我们在非同源站点如 gmail.com 的私人信息,这不仅侵犯了我们的隐私,同时也可能会损害我们的利益。为了避免此类安全问题的发生,用户代理(如浏览器)通常会遵循同源策略

根据 RFC6454 的定义,源(Origin)由协议(protocol)、主机(host)、端口(port)组成。对于两个 URL,当且仅当三者皆完全匹配时才被视为同源。

对于 http://www.example.com/a/b.html,同源检测结果如下表:

URL 同源 理由
http://www.example.com/a/c.html -
http://www.example.com/b.html -
https://www.example.com/a/c.html 不同 协议
http://example.com/a/c.html 不同 主机
http://www.example.com:3000/a/c.html 不同 端口

同源策略

对于涉及网络的 API,同源策略(Same-Origin Policy)分别处理发送与接受的请求。通常来说,一个源被允许发送信息至另一个源,但不被允许从另一个源接收信息。它阻止了恶意站点从其他站点读取机密信息,也阻止了网络内容合法地读取其他站点提供的信息。

根据 StackOverflow 上的这个答案,同源策略通常遵循以下规则:

  • 规则#1:不允许从不同源读取任何资源
  • 规则#2:允许写任意信息至不同源,但规则#1不允许读取响应
  • 规则#3:允许自由发送跨域的 GET 与 POST 请求,但无法控制 HTTP 头

对于不同标签、不同 API 同源策略的规则可能会有些许不同。

例如,我之前的一篇答案中引用了维基百科的图片,虽然其能正确的显示,但我无法读取该图像的信息(除了 DOM 元素的属性):

Cross Domain Image

内嵌非同源脚本时,浏览器虽然能正确加载,但在脚本内发生的具体错误是无法显示的,只有同源脚本的错误才能被捕获。如果我试图使用 ajax 请求非同源的脚本文件时,同样地,我们无法获得该脚本的内容。以 Google Analytics 的脚本为例,ajax 无法读取响应:

Cross Domain AJAX

在 Network 能看见发出的 ajax 请求。浏览器确实下载了文件,但由于同源策略,会阻止我们读取脚本内容:

Cross Domain AJAX Network

为何跨域

由于同源策略限制过于严格,但为了实现某些特定功能(或者出于性能考虑),我们需要实现合理的跨域请求。

如何跨域

聪明的开发者想出了很多方法(找到了很多漏洞)来解决这个问题。

document.domain + iframe

在同源策略中,存在例外情况,就是脚本(JavaScript)可以通过 document.domain 方法来改变本身的源。不过,只能设置为当前域的一个后缀(suffix)。例如,对于页面 http://baz.foo.com 来说,其 document.domain 只能设置为 foo.com。如果设置为其他的域,则会报错。在更改域之后,新的域则会作为后续同源检测的依据。注意,对 document.domain 的赋值会导致域的端口号被 null 覆盖,所以如果存在端口号,必须得加上。

利用这一特点,我们可以结合 document.domain 与 <iframe> 实现跨域。由于同源策略,当前页上非同源的 iframe 窗口里的内容我们是无法操作,但如果我们当前页与 iframe 窗口的域都改为相同(合法)域,则当前页与 iframe 页就能相互通信了。这样也就实现了跨域!

一个栗子:

假设父域为 http://foo.com,子域为 http://baz.foo.com

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
var iframe = document.createElement('iframe')
// 父域 -> 子域
iframe.src = 'http://baz.foo.com/iframe'
// 子域 -> 父域
iframe.src = 'http://foo.com/iframe'
// 发起请求方
iframe.style.display = 'none'
document.body.appendChild(iframe)
iframe.onload = function () {
document.domain = 'foo.com' // 设置为父域
function handle (data) {
console.log(data)
}
function get () {
// 在 iframe 里创建 XHR。如果该 iframe 是非同源的窗口,下面会报错
let xhr = new iframe.contentWindow.XMLHttpRequest()
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200)
handle(xhr)
}
// 如果使用当前页的 XHR,则会因跨域报错!(该 API 并没有 domain 之说~:joy:)
xhr.open('GET', 'http://baz.foo.com/api/get')
xhr.send()
}
}
// 接受请求方(iframe 窗口)
document.domain = 'foo.com' // 也设置为父域

JSONP

JSONP (JSON with Padding),是 Web 开发者用来克服浏览器跨域限制的一种 JSON 扩展。如我们所知,AJAX 虽能下载却无法读取非同源的资源,而对于外联的 <script> 标签,浏览器会正确的下载并执行、求值,只是用户无法读取其中内容。借助 <script> 标签的这一特征,我们可以通过 JSONP 拿到跨域数据。

举个栗子。假设存在一个 URL 提供 JSON 类型数据(可能动态从数据库查询),在 HTML 中插入一个链接为此的 <script> 标签并无法获得该处的数据,因为浏览器只把其解析为一个对象,并没有赋值或者别的什么东西。而如果使用 JSONP,<script> 标签 src 属性指向的那个 URL 提供的 JSON 数据必须被一段 JavaScript 代码包裹的数据。这段代码就会被浏览器解析并执行,请求方已定义的函数就能间接地获得 JSON 数据了。

(不过,使用 JSONP 也存在许多安全问题。譬如,在当前页面会注入来自某 URL 的任意内容,这会很危险…)

简易流程的请求与响应如下:

1
2
3
4
<!-- 请求 -->
<script type="application/javascript" src="http://example.com/12?=handle"></script>
<!-- 响应 -->
<!-- handle({ username: 'shawn', status: 'OK' }) -->

可以在控制台跑的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function cb (data) {
console.log(data)
}
// http://doc.jsfiddle.net/use/echo.html#jsonp
var jsonp = 'http://jsfiddle.net/echo/jsonp/?callback=cb&data=a'
// Pure AJAX
var ajax = new XMLHttpRequest()
ajax.onreadystatechange = function () {
if (ajax.readyState === 4 && ajax.status === 200)
console.log(ajax.responseText)
}
ajax.open('GET', jsonp, true)
ajax.send()
// With JSONP
var script = document.createElement('script')
script.src = jsonp
document.body.insertBefore(script, document.body.lastElementChild)

这是在控制台测试的结果:

JSONP

window.postMessage

window.postMessage 是 HTML5 引入的可控安全可跨域通信的 API。其语法如下:

1
otherWindow.postMessage(message, targetOrigin)
  • otherWindow:指的是被请求页面的 window 的引用,而不是当前页面
  • message:指的是要传输的数据,可以是任意类型
  • targetOrigin:指定了被请求页面的源应该满足的格式,可以是 URI(协议、主机、端口三者匹配),或者是 “*”,表示不加限制

多说不如栗子:

假设 A 站(http://acfun.com)要向 B 站(http://bilibili.com)发送一个消息,用 postMessage 该如何处理呢?

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// acfun.com
var iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = 'http://bilibili.com'
document.appendChild(iframe)
iframe.onload = function () {
function handle (xhr, method) {
if (method === 'GET')
console.log(method, xhr.responseText)
else if (method === 'POST')
console.log(method, xhr.responseText)
}
window.addEventListener('message', function (e) {
var message = e.data
handle(message.xhr, method)
})
// GET
function get () {
iframe.contentWindow.postMessage({
method: 'GET',
url: 'http://bilibili.com/api/1'
}, 'http://bilibili.com')
}
// POST
function post () {
iframe.contentWindow.postMessage({
method: 'POST',
url: 'http://bilibili.com/api',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
data: JSON.stringify({ id: 1 }),
}, 'http://bilibili.com')
}
}
// bilibili.com
window.addEventListener('message', function (e) {
var message = e.data
// 在接受到消息时就根据消息内容发送 XHR 来请求相应的数据并发回原站点
var xhr = new XMLHttpRequest()
if (xhr.headers)
for (var header in xhr.headers)
xhr.setRequestHeader(header)
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200)
// e.source 即为发送消息页的 window
e.source.postMessage({
method: message.method,
xhr: {
responseText: xhr.responseText
}
}, e.origin) // e.origin 为发送消息页的 url
}
xhr.open(message.method, message.url, true)
xhr.send(message.data)
})

CORS

前面提到的三种跨域手段,除了 postMessage,其它的看起来都有点黑魔法:sparkle:的感觉。如果能直接发请求,而不用整这些乱七八糟的东西就再好不过了。由于需求确实存在,W3C Web 工作组提出了一份新的建议标准,跨源资源共享(Cross-Origin Resource Share)。该机制能使得安全地进行跨域数据传输成为可能。该机制需要服务端的配合才可生效。

CORS 针对不同的请求有不同的处理方式,分为简单请求与非简单请求。

简单请求指的是满足以下三点的请求:

  • 方法为 GET / HEAD / POST
  • 除用户代理设置的头外,只设置了 Accept / Accept-Language / Content-Language / Content-Type
  • Content-Type 是以下三种之一:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

简单请求

浏览器会根据 URL 自动识别 XMLHttpRequest 是否跨域,如果跨域会自动为请求头添加 Origin 字段。发送出去后,如果响应头中包含 Access-Control-Allow-Origin 字段并且其值包含 Origin 的值,浏览器则不会拦截该响应。

例如,我们对 http://freegeoip.net/json/ 发出一个 XMLHttpRequest 请求,其请求与响应如图:

CORS Simple

如果对服务器未设置 Access-Control-Allow-Origin 响应头的发送请求,该请求会被视作跨域请求而被浏览器拦截。

浏览器对 XMLHttpRequest 发起的跨域请求默认是不允许发送 Cookies(与验证信息) 的,在上图中也有体现。但如果将 XMLHttpRequest 的 withCredentials 属性置为 true,浏览器就会允许 Cookies(与验证信息)的发送。

依然对 http://freegeoip.net/json/ 发出一个 XMLHttpRequest 请求,并设置 withCredentials 为 true,其请求与响应如图:

CORS Credentials

不过要注意的是,如果服务端能够响应带 Credentials 的请求,则其允许的源必须为请求者,而不能是 “*”。

预请求

对于非简单请求(不满足那三点),浏览器必须发送一个 OPTIONS 请求(预请求)给目的站点,来验证该跨域请求对目的站点来说是否可接受,以免造成安全事故。

1
2
3
4
5
6
7
8
9
var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200)
console.log(xhr.response)
}
xhr.open('PUT', 'http://freegeoip.net/json/', true) // 非 GET/POST/HEAD
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('X-RANDOM', '123')
xhr.send()

其请求与响应如图:

CORS Preflight

发现该服务并不支持非简单请求。

下面是支持非简单请求应有的响应:

CORS Option
CORS Request

koa 提供了一个简单的实现方案,koa-cors

适用场景

document.domain + iframe

  • 支持较老浏览器
  • 支持不止于 GET
  • 只需父、子域之间通信

JSONP

  • 支持较老浏览器
  • 只支持 GET
  • 支持跨完全不同的域

window.postMessage

  • 支持 IE8+,IE8/9 只支持在 iframes/frames 间传递消息,不支持弹出窗口;且 message 只能为字符串。:pill:
  • 支持不止于 GET
  • 支持跨完全不同的域

CORS (推荐)

  • 支持 IE8+
  • 支持不止于 GET
  • 支持自定义 HTTP 头
  • 支持发送 Cookies
  • 支持跨完全不同的域

参考文献

  1. RFC 6454 - The Web Origin Concept
  2. W3C - Same-Origin Policy
  3. MDN - Same-Origin Policy
  4. StackOverflow - Why do browser APIs restrict cross-domain requests?
  5. StackOverflow - Ways to circumvent the same-origin policy
  6. Wikipedia - JSONP
  7. MDN - postMessage
  8. MDN - HTTP Access Control
  9. 跨域资源共享 CORS 详解
  10. cross-domain-with-koa
Comments