In https response header, Does the value of Set-Cookie Domain need a leading dot?

没错,大家也看到了这篇博客的标题是英文,这说明了什么呢?这说明了这是一个悲伤的故事,预示着作者心中的哀伤(苦笑脸)。其实最主要的原因是,用中文描述起来可能有点啰嗦。

『Https请求的response的header中, Set-Cookie里的domain字段的值,是否需要以点.开头呢?』

为什么会有这样的疑问?一切还要从一个月前的那件事说起~


背景介绍

书归正文。先介绍一下业务背景。这次发生问题的登录业务。和大多数登录业务一样同样是基于Oauth登录,客户端访问授权方拿到授权,然后通过这份授权向认证服务器申请令牌(Token)。拿到Token后,客户端就可以合法的访问具体业务从而请求资源。大致的流程可以通过下图来表示

这次问题就发生在这个Token身上。客户端因为域名的调整导致拿不到Token了,引发了一系列问题。为了方便大家理解,我先简单的介绍下https关于下发Cookie的背景。

我们知道,https下发Cookie信息, 其中一种方式是通过把Cookie信息存放在Response的Header里面的Set-Cookie中。

假设下发Token的url是:

login.com

在Response的Header中,以Set-Cookie为Key,存有Token信息,格式为:

token=xxxxxx;path=/;domain=login.com

下图是我用Charles的抓包看response的header的截图,业务相关的地方打了马赛克,不影响理解。

而我们客户端要做的,就是从响应中取出这个Token的值,用于访问业务接口。而我们这边项目由于业务的原因,客户端这边从网络请求到拿Token实现方式有两套不同但又类似的代码,最终方式都是从请求的CookieManager中,直接根据key=token,取出对应的value。

  • 方式一:基于HttpURLConnection封装的网络请求,收到响应之后,从CookieManager中读取key为『token』的value。
  • 方式二:基于OkHttp封装的网络请求,同样的,收到响应之后,从CookieManager中读取key为『token』的value。

可以用这段代码理解:

1
2
3
4
5
6
@Override
public void onResponse(Call call, Response response) throws IOException {
HttpCookie serviceTokenCookie = CookieUtil.getCookie(getCookieManager(), "token");
//token不为空,表示成功拿到Token
String token = serviceTokenCookie.getValue();
}

暴露问题

这次事情的起因是公司的MIUI国际部隐私团队提出要求,海外版内置App需要杜绝直接连国内服务器进行网络请求的情况。而我们登录业务的callback也需要做调整。调整方案是统一替换原有callback的域名。替换为海外服务访问域名。

假设我们针对海外用户(比如说美国用户)的处理方式是提供海外子域名:

usa.login.com

确定了针对海外用户的域名后。在保证业务逻辑一致的情况下,我们在测试环境下切换了callback的链接 login.com -> usa.login.com。这时Set-Cookie的格式是:

token=xxxxxx;path=/;domain=login.com

但是我们发现:域名切换后,方法一基于HttpURLConnection封装的网络请求,CookieManager读取不到key为『token』的cookie信息。但是方法二正常。

时间仓促,我们只能紧急查找方案。有同事提出,CookieManager拿不到Token,是不是因为callback的url加了usa.的前缀后,和Set-Cookie的domain无法正常匹配?于是我们抱着试试看的想法,在domain现有的值前面加上一个点.,也就是格式变成:

token=xxxxxx;path=/;domain=.login.com

修改之后测试,结果又是大跌眼睛,方法一正常了,但是方法二基于OkHttp封装的网络请求,CookieManager读取不到key为『token』的cookie信息。

时间仓促,我们只好再次尝试,domain改成当前callback url完整域名,这下两种方法都能正常获取到token了。

token=xxxxxx;path=/;domain=usa.login.com

查找原因

作为一名程序猿,当然不能满足于只解决问题。我们首先查一下CookieManager的domain到底是怎么定义的?我们无意中在square/okhttp的issues中,发现了这个issue

Inject a leading . for better matching under JavaNetCookieJar #2722

里面核心的有这么一段话:

提到了两个关键词,『RFC 6265』和『RFC 2965』,然后又提到了okhttp遵循的是RFC 6265协议,而java.net遵循的是RFC 2965协议。通过这些线索,我们最后找到了原因。

HTTP State Management Mechanism

HTTP State Management Mechanism(Http状态管理机制),正是它规定了Set-Cookie中domain的写法。根据时间顺序,2000年10月份的RFC 2965协议在前,2011年4月份的RFC 6265在后。

RFC 2965

RFC 6265

可以看到,RFC 6265 同时声明了 RFC 2965 过时。

首先要说明的是,无论是哪份协议,都引入了一个概念,『domain-match』。请求头部的Set-Cookie的数据如果想合法的存到CookieManager中,需要保证url domain-match domain value string。如果不满足这个条件,CookieManager里也就不会有对应的数据。

RFC 2965

让我们首先看一下RFC 2965是如何定义domain-match的?

这里主机名可以是IP地址,也可以是主机域名。可以很清楚的看到,假设有A和B两个主机名,如何判定A domain-match B呢?满足两个条件之一即可:

  • A和B的主机名字符串相等,或者
  • A是一个主机域名并且其可以看做NB的组合,N是一个非空的字符串,而B的格式必须是是.B,同时B也是主机域名。(所以说x.y.com domain-match .y.com,但是并不 domain-match y.com)

根据这个定义,我们来复盘一下我们业务中的url和domain在RFC 2965的标准下,domain-match的情况分别是怎样?


业务调整前:

A(login.com), B(login.com),满足RFC 2965条件一 A equals B,所以业务调整前的A domain-match B。

业务调整后第一次:

A(usa.login.com), B(login.com),不满足RFC 2965的两个条件任何一个,所以A not domain-match B!!

业务调整后第二次:

A(usa.login.com), B(.login.com),满足RFC 2965条件二,所以A domain-match B !!

业务调整后第三次:

A(usa.login.com), B(usa.login.com),再次又满足RFC 2965条件一 A equals B,所以业务调整前的A domain-match B。


而基于HttpURLConnection封装的网络请求是源自于java.net,遵循RFC 2965,完全符合此前的现象!!

RFC 6265

让我们再来看看RFC 6265又是怎么定义 domain-match 的呢?指的注意的是,这里不再采用A,B这样的说法,而是相对应的用一个字符串(a string)和域名字符串(the domain string)来表示。

同样也是两个条件满足一个

  • 域名字符串和字符串是相同的。(注意,在这一情况时域字符串和字符串都将被规范化为小写)。
  • 以下所有条件同时成立
    • 域名字符串是该字符串的后缀
    • 该字符串的最后一个不包含在域名字符串的字符是%x2E(“.”)字符。
    • 该字符串是一个主机名(即不是一个IP地址)

第二个条件可能有点难理解。尤其是我们该怎么理解这里的『该字符串的最后一个不包含在域字符串的字符是%x2E(“.”)字符。』这句话呢?

假如域字符串是A.com,字符串是B.A.com,那么最后一个不包含在域字符串的字符就是%x2E(“.”)字符(从后向前算)。
如果域字符串是.A.com,字符串是B.A.com,那么最后一个不包含在域字符串的字符就是B字符。

同样的。我们来复盘一下两个domain在新标准RFC 6265下,domain-match的情况分别是怎样?


业务调整前:

A(login.com), B(login.com),满足RFC 6265条件一 A equals B,所以业务调整前的A domain-match B。

业务调整后第一次:

A(usa.login.com), B(login.com),满足RFC 6265的条件二,同样A domain-match B !!

业务调整后第二次:

A(usa.login.com), B(.login.com),RFC 6265 的两个条件都不满足,所以A not domain-match B !!

业务调整后第三次:

A(usa.login.com), B(usa.login.com),再次又满足RFC 6265条件一 A equals B,所以业务调整前的A domain-match B。


可以看到,基于新标准RFC 6265okhttp网络库的情况,与该标准描述的情况也完全符合,

解决方案

看到这,我们可以先得到一个结论。本次问题的出现,正是不同的网络请求库应用的HTTP State Management Mechanism标准不一致导致的。那么该如何处理呢?

其实很简单,放弃原先从CookieManager中读取的方式(虽然它省事,也符合标准),直接从response的header里手动解析,这样,无论Cookie遵循的是哪种规范,都不影响我们客户端最终读取数据。

事情到这本应该告一段落了,但不死心的程序猿们,想继续深入一下源码,看看到底是哪里的代码导致的问题呢?结果又有了意外发现。

分析源码

HttpURLConnection这部分没什么好说的,判断domain-match的方法也正如前面所说的,基于的是RFC 2965协议的标准。没有leading dot的话是不会判断为domain-match的。让我们好奇的是,okhttp基于的虽然是新的协议,但是为什么没有兼容老版本的协议呢?

我们这里的OkHttpClient是这样构造的:

1
2
3
4
5
6
7
8
9
10
mOkHttpClient = new OkHttpClient.Builder()
.dispatcher(
new Dispatcher(new ThreadPoolExecutor(6, Integer.MAX_VALUE, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(),
Util.threadFactory("OkHttp Dispatcher", false))))
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.cookieJar(new JavaNetCookieJar(mCookieManager = new CookieManager()))
.build();

可以看到我这里用的cookieJar是JavaNetCookieJar。而在JavaNetCookieJar用到的Cookie这个类发现了domainMatch(HttpUrl url, String domain):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static boolean domainMatch(HttpUrl url, String domain) {
String urlHost = url.host();

if (urlHost.equals(domain)) {
return true; // As in 'example.com' matching 'example.com'.
}

if (urlHost.endsWith(domain)
&& urlHost.charAt(urlHost.length() - domain.length() - 1) == '.'
&& !verifyAsIpAddress(urlHost)) {
return true; // As in 'example.com' matching 'www.example.com'.
}

return false;
}

而调domainMatch(HttpUrl url, String domain)之前,domain有经过处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Returns a domain string like {@code example.com} for an input domain like {@code EXAMPLE.COM}
* or {@code .example.com}.
*/
private static String parseDomain(String s) {
if (s.endsWith(".")) {
throw new IllegalArgumentException();
}
//注意这里!!
if (s.startsWith(".")) {
s = s.substring(1);
}
String canonicalDomain = domainToAscii(s);
if (canonicalDomain == null) {
throw new IllegalArgumentException();
}
return canonicalDomain;
}

我们看到这里JavaNetCookieJar主动的去除掉了domain前面的leading dot!!额?看到这就奇怪了,也就是说,其实okhttp是兼容了老版本协议带leading dot的情况。也就是说A(usa.login.com), B(.login.com)这种情况,B(.login.com)移除掉leading dot就变成B(login.com), 应该能A domain-match B 啊?

看到这,我不禁怀疑起来,那我们业务之前遇到的cookie取不到对应数据的原因到底是什么呢?这时,我又看到了这个issue:

okhttp Cookie strips off leading . from domain #2549

通过这份issue我们可以看到,这是okhttp当初的一个bug,虽然移除掉domain前面的leading dot,但是由于Android本身的CookiePolicy为ACCEPT_ORIGINAL_SERVER, 如果使用时结合JavaNetCookieStore和CookieManger时,这样的cookie数据是会被抛弃的!!CookieManager自然拿不到Token了。

并且这个bug在2016年7月份就commit修复。原来是我们项目中的okhttp版本号太旧了。因为项目中集成ReactNative的关系,目前使用的还是3.4.1的老版本,升级的话需要ReactNative这边各种依赖库同时升级,所以当前项目中的okhttp还是很早前的版本。不过正所谓无巧不成书,正好我们乘着这个机会,来看一下旧版本的okhttp为什么丢掉了这个cookie字段呢?

来看一下 JavaNetCookieJar.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
if (cookieHandler != null) {
List<String> cookieStrings = new ArrayList<>();
for (Cookie cookie : cookies) {
//问题出在这个cookie.toString(),在toString()之前,cookie的domain的leading dot就被抛删掉了
cookieStrings.add(cookie.toString());
}
Map<String, List<String>> multimap = Collections.singletonMap("Set-Cookie", cookieStrings);
try {
cookieHandler.put(url.uri(), multimap);
} catch (IOException e) {
Platform.get().log(WARN, "Saving cookies failed for " + url.resolve("/..."), e);
}
}
}

再来看一下Cookie的toString()方法

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
@Override public String toString() {
StringBuilder result = new StringBuilder();
result.append(name);
result.append('=');
result.append(value);

if (persistent) {
if (expiresAt == Long.MIN_VALUE) {
result.append("; max-age=0");
} else {
result.append("; expires=").append(HttpDate.format(new Date(expiresAt)));
}
}

if (!hostOnly) {
//问题在这里,还记得okhttp将domain的leading dot给移除了对嘛?这里原本domain已经不是.login.com,而是login.com
result.append("; domain=").append(domain);
}

result.append("; path=").append(path);

if (secure) {
result.append("; secure");
}

if (httpOnly) {
result.append("; httponly");
}

return result.toString();
}

前面我们提到JavaNetCookieJar配合Android的CookieManager会导致这个bug。果不其然我们看到put(URI uri, Map<String, List> responseHeaders)方法,
我们发现,cookieJar存cookie的时候,不是所有的cookie都被存下来了。而是有一步过滤!!也就是这个shouldAcceptInternal()方法,那这到底过滤掉什么cookie呢?

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
public void put(URI uri, Map<String, List<String>> responseHeaders)
throws IOException {
//...省略若干代码...
// Domain Defaults to the effective request-host. (Note that because
// there is no dot at the beginning of effective request-host,
// the default Domain can only domain-match itself.)
if (cookie.getDomain() == null) {
cookie.setDomain(uri.getHost());
}
String ports = cookie.getPortlist();
if (ports != null) {
int port = uri.getPort();
if (port == -1) {
port = "https".equals(uri.getScheme()) ? 443 : 80;
}
if (ports.isEmpty()) {
// Empty port list means this should be restricted
// to the incoming URI port
cookie.setPortlist("" + port );
if (shouldAcceptInternal(uri, cookie)) {
cookieJar.add(uri, cookie);
}
} else {
// Only store cookies with a port list
// IF the URI port is in that list, as per
// RFC 2965 section 3.3.2
if (isInPortList(ports, port) &&
shouldAcceptInternal(uri, cookie)) {
//只有符合条件的cookie,才会被存下来塞到CookieManager中
cookieJar.add(uri, cookie);
}
}
} else {
if (shouldAcceptInternal(uri, cookie)) {
cookieJar.add(uri, cookie);
}
}
}

再结合CookiePolicy的代码一看就了然了

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface CookiePolicy {

//...省略若干代码...

/**
* One pre-defined policy which only accepts cookies from original server.
*/
public static final CookiePolicy ACCEPT_ORIGINAL_SERVER = new CookiePolicy(){
public boolean shouldAccept(URI uri, HttpCookie cookie) {
return HttpCookie.domainMatches(cookie.getDomain(), uri.getHost());
}
};
}

没错,如果CookiePolicy是ACCEPT_ORIGINAL_SERVER的话,过滤掉的正是不符合HttpCookie.domainMatches()的cookie。而HttpCookie.domainMatches(),是遵循老版本RFC 2965协议的!!okhttp把domain的leading dot去掉,虽然通过了自己本身的domainMatch()方法,但是没想到,Android系统又过滤了一道,真是太坑了!!

那okhttp是如何修复这个bug的呢?大家可以参考okhttp这个commiit

Inject a leading . for better matching under JavaNetCookieJar

其实也很简单,就是第一个okhttp的domainMatch()方法判断前,去掉leading dot。然后在第二步Android系统的domainMatch()方法判断前,再把leading dot主动加回来!!

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
/**
* @param forObsoleteRfc2965 true to include a leading {@code .} on the domain pattern. This is
* necessary for {@code example.com} to match {@code www.example.com} under RFC 2965. This
* extra dot is ignored by more recent specifications.
*/
String toString(boolean forObsoleteRfc2965) {
StringBuilder result = new StringBuilder();
result.append(name);
result.append('=');
result.append(value);

if (persistent) {
if (expiresAt == Long.MIN_VALUE) {
result.append("; max-age=0");
} else {
result.append("; expires=").append(HttpDate.format(new Date(expiresAt)));
}
}

if (!hostOnly) {
result.append("; domain=");
if (forObsoleteRfc2965) {
//就是这里,为了兼容RFC 2965协议,主动补充一个leading dot
result.append(".");
}
result.append(domain);
}

result.append("; path=").append(path);

if (secure) {
result.append("; secure");
}

if (httpOnly) {
result.append("; httponly");
}

return result.toString();
}

总结

总结一下,啰哩啰嗦说了这么多,其实可以简单总结成以下几点:

  1. HTTP State Management Mechanism协议针对domain-match的定义,从RFC 2965RFC 6265有一个条件发生了很大的变化,也就是要不要leading dot的问题。RFC 2965 协议是要求一定要有leading dot的,而RFC 6265协议则明确要求不可以有leading dot。

  2. 在Android的网络请求库中,okhttp遵循的是RFC 6265协议,而java.net遵循的是RFC 2965协议。这是一个大坑,也就是意味着,如果我们采用从CookieManager中读取数据的方式的话,都需要注意着ddomain-match定义的兼容性。

  3. okhttp不愧是Android最优秀的网络请求库,最新版本已经修复了leading dot导致的在Android CookieManager中使用JavaNetCookieJar的bug。也就是说,使用okhttp是可以做到两种协议在domain-match定义上的兼容。这对开发者来说是好事情。但是,可能你和我们一样,项目中引入了ReactNative导致很多库的版本不是最新版,还带着老版本的bug。所以,强烈建议Android开发者手动从Header中解析Set-Cookie,而不是依赖CookieManager

  4. 问题本身很简单,但是表象可能很复杂。所以,遇到问题我们要尽可能的深入,找到原因。

参考文献

  1. HTTP、Cookie、Session、token
  2. 聊一聊 cookie
  3. HTTP State Management Mechanism 6265
  4. HTTP State Management Mechanism 2965
  5. cookie规范(RFC 6265)翻译
  6. HTTP 状态管理机制——RFC6265翻译文档
  7. Http 状态管理机制(cookie) (译文)