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 2965到RFC 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) (译文)