瘦人说

跨了个域

如今是一个平台众多,软件开发平民化,软件服务可拼凑成产品的时代。优秀的互联网产品,像微博,微信,Github,Instagram都变成平台,提供一些优雅或不优雅的API让靠谱或不靠谱的开发者来玩,这些API里肯定会涉及到跨域访问的知识。靠谱的开发者即使遇到没有开发文档也能摸索出套路,反之就需要了解下跨域访问的知识了。

为什么需要“踩过界”

跨域访问指的是不同源的A和B网站,A请求访问B的资源。资源包括B网站的图片、视频、数据、文件等等。例如,新浪微博提供的微博API就是提供给不同源网站访问微博数据的接口;又如,A网站嵌入了B网站的图片或视频资源也属于跨域访问。

那么,为什么不能直接访问呢?等等,让我们先来聊聊浏览器同源策略(Same-Origin Policy),同源策略限制了本网站的脚本不能对其他源的网站资源进行操作。两个域必须满足协议(protocol)、端口(port)、主地址(host)都是一致的,才能相互请求资源。

假设有不同源的源A和源B,源A对源B的读操作指的是:

  1. 读取源B的JavaScript,CSS的源代码(受到限制)
  2. 读取源B的返回的文档,JSON等数据(受到限制)
  3. 读取源B的图片的二进制数据(受到限制)

源A对源B的写操作指的是源A发送数据到源B,主要包括:

  1. 源A对源B进行表单,Ajax(XMLHttpRequest或XDomainRequest)POST方式提交
  2. 源A嵌入指向源B的链接,点击后发生的跳转
  3. 源A的脚本操作嵌入到iframe中的源B的DOM对象(受到限制)
  4. 源A使用postMessage发送嵌入到iframe中的的源B

以上读和写操作,加上了(受到限制)的操作都属于浏览器同源策略限制。

其中最重要的危害最大的是写操作中的对源B的DOM对象进行操作。就以支付宝做一个例子,作为攻击者,我申请了一个网址叫做http://alipay.tb.com,整个网站没有内容就一个隐藏的iframe嵌入了http://alipay.com,因为支付宝会记录登陆用户的cookie为了让用户不用每次访问都需要登陆,在没有同源策略的保护下,我可以控制你支付宝,获取你的余额记录和消费记录(此时没有了读取文档限制)或直接利用脚本帮我进行支付,而用户不会洞察到什么,只是收到了消费的短信通知,钱财不翼而飞,损失很大。可见在没有同源策略下的互联网是多么的恐怖。不过放心,即使没有同源策略我们的支付宝也没有这么弱,支付时还需要支付密码。

不过光有浏览器同源策略是不能完全阻止脚本在你已经登陆的网站上肆虐操作,如果用户主动点击了页面上加载不安全脚本的链接,不安全脚本也会趁虚而入,我们把这种攻击叫做XSS(Cross Site Scripting)。目前可能遇见的方式有这么几种:1、点击了链接加载不安全脚本,2、保存成bookmark的脚本不安全,3、Chrome插件被注入恶意脚本。这里就有个血淋淋的新浪微博遭到XSS攻击的例子

在读取操作中有一项限制很有趣,就是不同源之间读取文档,JSON数据是受限制的。提供API就是为了让不同源之间可以相互访问,但是因为同源策略的存在,源B的JSON数据不能直接被读取到,导致现在出现了各种各样的跨域访问方式,例如JSONP,CORS(Cross Origin Resource Sharing),后台代理等。

跨域访问需要跨越的就是同源策略的多个限制。现在我们来看看几种使用广泛的跨域访问方式是如何工作的。

跨域请求方式

JSONP

JSONP(JSON with padding)是目前使用的最广,最简单的一种跨域访问方式。既然说同源策略限制了脚本于不同源网站资源进行交互,那么JSONP的原理是什么呢?其实同源策略中还存在另一种脚本和资源的交互方式,叫做嵌入(Embed)方式。也就是说,源A可以嵌入源B的资源,比如嵌入源B中的图片、样式文件、脚本文件。样式文件和脚本文件嵌入之后会被运行。JSONP就是利用了网站可以嵌入脚本并运行这一点。

让我们来看一个列子。定义了简单的后端HTTP GET服务,每次请求返回一段脚本代码,并且把后段数据“hello world”发送回去。

1
2
3
4
5
6
7
8
9
10
def jsonp
str = 'hello world'
func = params[:callback]
render :js =>
"try{" +
"#{func}({ str : '#{str}'});" +
"}catch(e){" +
"console.error('request error');" +
"}"
end

实现一个简单的前端调用,使用的是jQuery的getJSON方法

1
2
3
4
$.getJSON('http://localhost:3000/jsonp?callback=?', function (ret) {
console.log(ret.str); // output "hello world"
});

在调用getJSON方法之后发送了callback参数告诉后端生产的JavaScript代码需要调用这个方法,并且把后端的资源“hello world”准备成该方法的一个参数返回回来,达到跨域访问的目的。为了更仔细观察整个过程,我们来看看HTTP的包是什么样子的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Request
GET /jsonp?callback=jQuery19008820020072162151_1359426298971 HTTP/1.1
Host: localhost:3000
Connection: keep-alive
Cache-Control: max-age=0
// Response
HTTP/1.1 200 OK
Content-Type: text/javascript; charset=utf-8
Etag: "a60520e11c6991b89099c84cb4742f39”
Cache-Control: max-age=0, private, must-revalidate
Content-Length: 112
try{jQuery19008820020072162151_1359426298971({ str : 'hello world' });}catch(e){console.error('request error');}

需要注意的是jQuery为我们准备的方法叫做jQuery19008820020072162151_1359426298971,这和response内容中的方法调用对应。另一个需要注意的是,response的Content-Typetext/javascript; charset=utf-8,在调用方来看想要得到的是JSON数据,不是脚本,response中的描述出现了“错误”。

JSONP方式请求,实质上是一个对脚本的GET请求,它不像Ajax请求那样存在加载,调用完成等状态;安全性也不是特别高,存在加载不安全的脚本在本站运行具有风险。jQuery提供的getJSON这样的方式让开发者掉用起来不会感觉和Ajax调用有多大差别。虽然它并不完善,但它的易用性使它现在适用范围最广的跨域访问方式,目前几乎所有的API都会准备JSONP调用方式。

CORS

CORS(Corss Origin Resource Sharing)跨域资源共享是真正用来解决资源共享问题。不像JSONP那么旁门左道,它是通过HTTP方式来实现资源共享,让每个请求的服务直接返回资源。它使用了HTTP交互方式来确定请求源是否有资格请求该资源,并且通过设置HTTP Header来控制访问资源的权限。

我们来看一个例子。同样还是定义后端服务,返回的资源仍然是“hello world”。

1
2
3
4
5
6
7
8
def cors
response.headers['Access-Control-Allow-Origin'] = "*"
response.headers['Access-Control-Allow-Methods'] = "GET"
response.headers['Access-Control-Max-Age'] = '60'
ret = { :str => 'hello world' }
render :json => ret.to_json
end

看到了一些HTTP Header的配置,过会再来分析它们是什么。接着,使用jQuery简单实现前端调用。

1
2
3
$.getJSON('http://localhost:3000/cors', function (ret) {
console.log(ret.str); // output "hello world"
});

虽然结果是一样的,但其实其中经过了很多过程,我们看看HTTP请求过程就知道。第一次请求后端时候,浏览器意识到是访问一个跨与资源,没有直接发送GET请求获取数据,而是发送了一个OPTIONS请求询问是否可以访问该资源。我们称之为Preflight请求。

1
2
3
4
5
6
7
OPTIONS /cors HTTP/1.1
Host: localhost:3000
Origin: http://localhost:8000
Accept: application/json, text/javascript, */*; q=0.01
Access-Control-Request-Method: GET

浏览器尝试用GET方式请求资源http://localhost:3000/cors,默认因为同源策略的存在,肯定是请求失败的,所以经常看到的一个错误XMLHttpRequest cannot load http://localhost:3000/. Origin XXX is not allowed by Access-Control-Allow-Origin. 就会出现。这个例子中,Preflight请求的到的response是

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2013 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
Access-Control-Max-Age: 60
Content-Encoding: gzip
Content-Length: 0
Connection: Keep-Alive
Content-Type: text/text

通过response,服务器告知浏览器所有域都可以使用GET方式请求请求该资源,有效时间为60秒。得到回复后,浏览器自动再次发出真正的请求并且得到真正的资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Actual Request
GET /cors HTTP/1.1
Host: localhost:3000
Origin: http://localhost:8000
Accept: application/json, text/javascript, */*; q=0.01
Referer: http://localhost:8000/temp.html
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-US,en;q=0.8
// Return Resource
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
Access-Control-Max-Age: 60
Content-Type: application/json; charset=utf-8
Content-Length: 24
{ "str" : "hello world" }

和JSONP方式不同,资源最终返回到请求方时并不是脚本代码,而且资源本省,response包的Content-Type也正确地对资源进行了描述。虽然是两次请求,但是浏览器自动处理,开发者并不需要做其他处理。BTW,现在还有很多人分不清楚JSON数据对象和JavaScript对象的联系和区别。

CORS使用HTTP Header来控制资源的可访问性,可以使用的属性主要有:

1
2
3
4
5
Access-Control-Allow-Origin: <origin> | * // 授权的源控制
Access-Control-Max-Age: <delta-seconds> // 授权的时间
Access-Control-Allow-Credentials: truefalse // 控制是否开启与AjaxCookie提交方式
Access-Control-Allow-Methods: <method>[, <method>]* // 允许请求的HTTP Method
Access-Control-Allow-Headers: <field-name>[, <field-name>]* // 控制哪些header能发送真正的请求

CORS方式我很喜欢,目前支持的浏览器有Chrome 4+, FF 3.5+, IE 8+, Opera 12+, Safari 4+,虽然不是全都支持,但是这种优雅的方式会渐渐取代JSONP成为主流,目前发现的Github, W3C文档都已经实现。

后端请求

后端请求没有存在同源策略,因为后端请求不会发送Cookie到后端,不存在登陆过的网站Cookie被其他域的网站调用的情况,所以后端请求也经常用来作为跨域访问。有时提供资源方只让授权的第三方请求数据,往往会给予一个token个第三方,作为请求时的验证信息。这种做法是非常安全的。

前端需要显示跨域资源时,往往时发送请求到自己的后端,通过后端请求到跨域资源并且返回,像代理一样工作。比起前两种,无疑需要更多的开发工作去设计并实现包装的接口。此方式比较简单,我们看一个请求新浪微博的例子。

1
2
3
4
5
6
7
import httplib
conn = httplib.HTTPSConnection("api.weibo.com")
conn.request("GET",
"/2/statuses/user_timeline.json?source=3168xxx&uid=264xxx")
response = conn.getresponse()
print response.read()
conn.close()

其他方式

当然还有很多跨域方式,在这就不一一详细介绍了:

  • 域与子域之间的跨域访问通过document.domain来解决
  • 新的HTML5中window.postMessage API可以让消息在多个窗口中传递
  • 还有之前flash使用的crossdomain.xml文件

更多理论的介绍可以去Google上搜索,不过还时推荐几篇文章给大家,Same-origin Policy – MDNSame Origin Policy Part1 – No PeekingSame Origin Policy Part2 – Limited WriteCORS – MDN

Comments

Proudly published with Hexo