CORS笔记

相信有不少服务端开发同学和我类似,初次接触CORS的体验是这样的:你这个服务调不通啊,“跨域”了,要打开CORS。什么?CORS干嘛的?然后有人告知你要在服务端响应header里加上这个、这个和这个,就好了,几乎是口口相传,代码拷贝粘帖,然后bingo,然后就没有然后了。

作为有情怀的服务端开发,怎么甘心被前端同学这么牵着鼻子走呢你说是不是?DISCLAMER:想全面学习CORS请考虑购买《CORS in Action》[1],目前最好的介绍CORS的书。这里只是简单结合实际项目中的心得做个笔记。

CORS的全称是Cross-Origin Resource Sharing,即跨源资源共享,常用于Web前端代码访问服务端暴露的API,比如去flickr加载图片之类。什么是“源”?为什么不说“跨域”?这里要澄清几个概念。首先,参与CORS的角色主要有:JavaScript代码(客户端)、浏览器、服务端。浏览器说白了就是给JavaScript的运行提供一个沙箱,为了保护最终用户的安全,随着网页打开而运行JavaScript代码能做的事情相当受限,其中一个重要的约束就是,JavaScript默认只能对同“源”的服务端资源发起请求。除非服务端特别允许,浏览器会拦截掉JavaScript的跨“源”请求。也就是说,要实现CORS,JavaScript代码、浏览器和目标服务端都必须主动参与,JavaScript要发起请求,浏览器加工header并允许调用,而服务端也必须声明允许来自某个或某些“源”的JavaScript请求,整个调用才会成功。注意这里特别强调的浏览器的作用,如果是用curl之类的命令行工具,CORS调用并没有“浏览器”的参与,也就没有来自浏览器的隐含限制,只有curl和服务端之间的交互,至少在curl执行时看起来并不会被自动带上CORS的痕迹,而浏览器通常会。

那么,对于浏览器而言,如何判断JavaScript对外发起的请求属于“同源”还是“不同源”呢?所谓的“源”,就是资源来自哪里,比如承载JavaScript的网页所在的站点,以及JavaScript试图访问的服务端地址,等等。构成“源”的信息包括:协议(http或https)、主机地址(比如laogao.me)、端口。如下是一些示例:

URL
http://localhost:9700 http://localhost:9700
http://localhost:9700/a.html http://localhost:9700
https://localhost:9443/login https://localhost:9443
http://127.0.0.1/some_api http://127.0.0.1

注意localhost和127.0.0.1不会被识别为相同的主机地址,浏览器几乎是靠文本匹配来判断网页地址和请求目标地址是否同“源”。比如在 http://localhost:9700/a.html 这个网页上,有一段JavaScript,想对 http://127.0.0.1/some_api 发起请求,浏览器会认为这是跨“源”调用。

知道了浏览器如何判定跨“源”,对于那些我们希望成功的客户端对服务端资源的跨“源”请求,我们如何让浏览器和服务端允许呢?主要的开发工作还是在服务端:

  1. JavaScript客户端发起跨“源”请求。
  2. 浏览器给请求加上Origin头,内容为前面我们表格里看到的那样的“地址”。
  3. 服务端收到带Origin头,并且确实来自不同“源”的请求,如果允许,则需要在响应中加上HTTP头Access-Control-Allow-Origin: *,其中*表示所有源都允许,当然我们也可以明确指出我们允许某个具体的“源”。
  4. 浏览器截获该响应,判定服务端已允许来自发起调用的JavaScript代码所在“源”的请求,将结果返回给JavaScript客户端。

这是最简单的支持CORS的方式,只要服务端加上一个响应头Access-Control-Allow-Origin: *即可。不过现实情况很少会这么简单,通常服务端资源都不是完全公开的,因此需要某种限制或保护,比如仅针对某些特定“源”放开,甚至是查验cookie等。针对特定“源”相对好办,服务端判断一下,来自白名单的“源”,显示允许即可;查验cookie稍微麻烦点,除了客户端JavaScript调用时给XMLHttpRequest对象设置withCredentials为true之外,还需要服务端响应Access-Control-Allow-Credentials: true,如果不这样做,浏览器默认是不会在请求中带上cookie的。而一旦加上了Access-Control-Allow-Credentials: true,浏览器将不允许Access-Control-Allow-Origin: *这样的通配写法,而必须显式指出允许的“源”,且要与请求中Origin头的值一致。

说到这里可能很多人都会有疑问,都已经到响应阶段了,该带的cookie也都带了,这样的头有意义吗?其实前面我们忽略了一个细节,就是preflight请求。不同浏览器实现稍有不同,并且默认也不是每一类或每一个请求都做,但基本逻辑是:在实际的CORS请求发起之前,浏览器向服务端发起一个试探性的OPTIONS请求,询问服务端是否支持CORS以及支持哪些方法和哪些请求头,这些都确认OK之后,才发起真正的请求。如果我们想做精细控制,服务端就得感知这件些请求/调用细节,并作出正确合理的响应。

最后列一下最常见的CORS相关请求头/属性和响应头,看上去还挺工整的,使用时注意下应该不至于搞错:

请求头或属性 响应头 备注
Origin Access-Control-Allow-Origin 发起源和服务端允许的源
withCredentials Access-Control-Allow-Credentials 请求是否带cookie和服务端是否允许带
Access-Control-Request-Methods Access-Control-Allow-Methods 请求使用的HTTP方法和服务端允许的方法
Access-Control-Request-Headers Access-Control-Allow-Headers 请求带上头和服务端允许的头

[1] http://www.manning.com/hossain/

超短域名下种cookie的一个坑

不久前踩了个略不容易踩到的坑儿,做个记录。

接到任务要做个统一的Web登录入口:通过某特定子域名的地址在主域下种cookie,以便为部署在其他子域的Web应用提供登录服务。从开发到测试再到预发,一切都很顺利,关联系统也都全部跑通。可到了线上环境,IE和IE内核的浏览器却跟商量好了似的,瞬间全线飘红,cookie无法顺利下发,与此同时,Chrome正常,Firefox正常,Safari正常,Opera也正常。什么情况?!

仔细对比了一下预发和线上两个环境的区别,配置项方面,只有域名不同,预发使用login.pre.xx.yy(主域.pre.xx.yy),线上使用login.xx.yy(主域.xx.yy),莫非……?

缓过神来,google了一圈,原来IE对下发xx.yy这样的超短域名的cookie有个比较奇葩的限制[1]:除.pl和.gr等极少数顶级域名外,如果域名最后一段为2个字符且倒数第二段少于3个字符,即类似co.uk这样的超短域名,不允许在Set-Cookie时显式指定domain,要想下发匹配超短域名的cookie,除非当前提供服务的域名本身不多不少就是xx.yy。从某种意义上,尤其站在历史视角,似乎也能理解,因为除了官方机构,哪个公司、组织或个人需要在co.uk这样的域下去种cookie呢,想干坏事吧?于是乎IE就这样被加上了这么个当时看上去充满正能量的逻辑,而这个逻辑直到最新的IE版本中依然存在。

好了,问题找到,要怎么解决呢?首先我的需求是要在一个登录系统中种下其他子域可以获取的Http-Only的cookie,比如我Set-Cookie下去,希望这个cookie活在xx.yy这个主域下,其他所有abc.xx.yy、def.xx.yy、blahblah.xx.yy等等,都能在服务端取到这个cookie。既然IE不允许我指定domain,看来也只有硬着头皮在xx.yy这个域名下占块地儿了(本来不想这么干)。跟运维同学商量,得,给,/login,拿去吧。

占地儿的事情告一段落,接下来就是技术实现,这里又发现个挺有趣的实现细节:IE内核浏览器,cookie的domain前面不加.(比如xx.yy),访问子域时才会自动带上;其他浏览器,Set-Cookie时通过指定domain,domain名称前面默认加.(比如.xx.yy),访问子域时会自动带上主域的cookie,如果不显式指定domain则访问子域时不会自动带上主域的cookie。好嘛,至少这种不一致表现起来还是挺“一致”的。唔,有戏!上User-Agent!

至此,这个坑终于可以宣告“已填”。

P.S. 事后回想,可能最大的疏漏,就是在开发测试阶段没有严格模拟线上环境的所有参数,导致问题直到线上环境才暴露出来,经验教训啊!话说回来,职业生涯能用得上几回xx.yy这么短的域名?能踩上这样的坑,也够奢侈的。

[1] http://crisp.tweakblogs.net/blog/ie-and-2-letter-domain-names.html

《快学Scala》勘误

第11页(练习):

在Scala REPL中键入3,然后按Tab键 应为 在Scala REPL中键入3.,然后按Tab

第19页(正文):

util方法返回一个并不包含上限的区间 应为 until方法返回一个并不包含上限的区间

第19页(代码):

0 util s.length 应为 0 until s.length

第31页(正文):

util是RichInt类的方法 应为 untilRichInt类的方法

第34页(代码):

ArraryBuffer 应为 ArrayBuffer
b.sorted(_ < _) 应为 b.sorted
b.sorted(_ > _) 应为 b.sortWith(_ > _) 

第44页(代码):

scala.collections.immutable.SortedMap 应为 scala.collection.immutable.SortedMap

第107页(代码):

val tokens = source.mkString.split("\\S+") 应为 val tokens = source.mkString.split("\\s+")

第341页(代码):

var count: (Int => Double) => null 应为 var count: (Int => Double) = null