Chrome 80が密かに呼び寄せる地獄 ~ SameSite属性のデフォルト変更を調べてみた
巷では、今月リリースされた chrome80 の、Set-Cookie ヘッダ SameSite 属性のデフォルト値変更が話題になっていますが、私が関わっているサービスでもご多分に漏れず影響が出ることが発覚したため、調査していました。
その過程で chrome80 の挙動を見ていたところ、気になる点があったので深堀りしてみました。
「サードパーティ」とは
私が担当しているサービス(ここでは「連携元サービス」)では、あるWebページを提供しており、そのページ内の img
タグで他の複数の連携サービスの画像にリンクを張っています。各サービスは別々のドメインで提供されていて、画像読み込み時にサードパーティコンテキストで Cookie が送信される仕組みになっています。各連携サービスではまだ、SameSite=None
の対応はされていない状態でした。
各サービスへの影響を調査するため、chrome80 で連携元サービスのページにアクセスしてみたところ、連携サービスのドメイン名によって影響の有無に違いがあることが分かりました。具体的には、連携元サービスはドメイン aaa.zzz.hoge.jp
で提供されているのですが、連携先のドメインによって以下のように挙動が違っていました。(要旨に影響しない範囲で各ドメイン名は改変しています)
連携先サービスのドメイン | cookie 送信 |
---|---|
bbb.jp | x |
ccc.fuga.hoge.jp | o |
これだけ見ると、「まあそうなるか」という感想しかないですが、ここで疑問が湧いてきます。
-
www.foo.co.jp
とwww.bar.co.jp
はお互いサードパーティ扱いにならないと絶対マズいよね? -
www.foo.co.jp
/www.bar.co.jp
とaaa.zzz.hoge.jp
/ccc.fuga.hoge.jp
の違いは何?
というわけでまずは実験してみました。
実験
以下のようなサーバを2台用意します。
- 連携先サーバ
- nginx + SSL(自己署名証明書)
- アクセスすると Set-Cookie を返す
/set_cookie
- アクセスすると Cookie の内容をログに出力し、画像を返す
/log.jpg
- 連携元サーバ
- nginx + SSL(自己署名証明書)
- 静的な
test.html
を用意し、img
タグで/log.jpg
にリンク
自己署名証明書は以下のコマンドで作成します。
# openssl genrsa 2048 >server.key
# openssl req -new -key server.key > server.csr
# openssl x509 -days 365 -req -signkey server.key < server.csr > server.crt
/set_cookie
は nginx.conf
内で Lua スクリプトを利用して以下のように実装します。
location /set_cookie {
content_by_lua "
ngx.header['Content-Type'] = 'text/plain'
ngx.header['Set-Cookie'] = 'foo=by_set_cookie;Secure'
ngx.say('OK')
";
}
/log.jpg
は以下の通りです。
location /log.jpg {
content_by_lua "
ngx.header['Content-Type'] = 'image/jpeg'
local img_file = io.open('/var/tmp/hoge.jpg')
local stream = img_file:read('*a')
ngx.say(stream)
img_file:close()
local log_file = io.open('/var/tmp/debug_log', 'a')
local v = '-'
if ngx.var.cookie_foo then
v = ngx.var.cookie_foo
end
log_file:write('log.jpg ')
log_file:write(s)
log_file:write('\\n')
log_file:close()
";
}
test.html
には以下のように img
タグを書いています。
...
<img src="https://(連携先サーバ)/log.jpg">
この準備を行った上で、連携先サーバと連携元サーバのドメインを変えながら、以下の手順を繰り返します。
1.連携先サーバの /set_cookie
にアクセス
2.連携元サーバの test.html
アクセス
3.連携先サーバの /log.jpg
に Cookie が送信されているかをログで確認
クライアントには Ubuntu Linux にインストールした chrome80 を利用し、/etc/hosts
を書き換えて各サーバのドメインを変更しながら実験します。
結果は以下の通りになりました。
連携先サーバ(Cookie発行)ドメイン | 連携元サーバドメイン | 連携先サーバへのCookie送信 | |
---|---|---|---|
(1) | bbb.zzz.com | aaa.zzz.com | o |
(2) | zzz.bbb.com | zzz.aaa.com | x |
(3) | bbb.zzz.co.jp | aaa.zzz.co.jp | o |
(4) | zzz.bbb.co.jp | zzz.aaa.co.jp | x |
(5) | zzz.bbb.hoge.jp | zzz.aaa.hoge.jp | o |
(6) | zzz.yyy.hoge.jp | zzz.yyy.fuga.jp | x |
この結果を見ると、(1)(2)は第2レベルドメインが一致するかどうかで判定されており、(3)(4)は第3レベルドメインで判定されているようです。(5)(6)は、階層は(3)(4)と同じなのに、第2レベルドメインで判定されています。
Chrome のソースを追ってみる
実験の結果からなんとなく「co.jp
で終わるドメインは co.jp
の直前のパートで判定して、(よく分からないもの).jp
で終わるドメインは .jp
の直前のパートで判定する」という動作をしていそうですが、よく分からないので Chrome のソースを追ってみます。
chrome のソースコードは https://chromium.googlesource.com/chromium/ にあるように、
$ git clone https://chromium.googlesource.com/chromium
でチェックアウトできます。
とりあえずサードパーティcookieに関連しそうな語で grep してみると、net/base/static_cookie_policy.cc
で CanGetCookies
、CanSetCookie
というメソッドが定義されています。
int StaticCookiePolicy::CanGetCookies(
const GURL& url,
const GURL& first_party_for_cookies) const {
switch (type_) {
case StaticCookiePolicy::ALLOW_ALL_COOKIES:
case StaticCookiePolicy::BLOCK_SETTING_THIRD_PARTY_COOKIES:
return OK;
case StaticCookiePolicy::BLOCK_ALL_THIRD_PARTY_COOKIES:
if (first_party_for_cookies.is_empty())
return OK; // Empty first-party URL indicates a first-party request.
return RegistryControlledDomainService::SameDomainOrHost(
url, first_party_for_cookies) ? OK : ERR_ACCESS_DENIED;
case StaticCookiePolicy::BLOCK_ALL_COOKIES:
return ERR_ACCESS_DENIED;
default:
NOTREACHED();
return ERR_ACCESS_DENIED;
}
}
これによると、type_
という変数に StaticCookiePolicy::BLOCK_ALL_THIRD_PARTY_COOKIES
という値が設定されている場合は、RegistryControlledDomainService::SameDomainOrHost(url, first_party_for_cookies)
の結果で Cookie へのアクセス可否が決まるようです。(CanSetCookie
も同様)
SameDomainOrHost
の定義は net/base/registry_controlled_domains/registry_controlled_domain.cc
にありました。
// static
bool RegistryControlledDomainService::SameDomainOrHost(const GURL& gurl1,
const GURL& gurl2) {
// See if both URLs have a known domain + registry, and those values are the
// same.
const std::string domain1(GetDomainAndRegistry(gurl1));
const std::string domain2(GetDomainAndRegistry(gurl2));
if (!domain1.empty() || !domain2.empty())
return domain1 == domain2;
// No domains. See if the hosts are identical.
const url_parse::Component host1 =
gurl1.parsed_for_possibly_invalid_spec().host;
const url_parse::Component host2 =
gurl2.parsed_for_possibly_invalid_spec().host;
if ((host1.len <= 0) || (host1.len != host2.len))
return false;
return !strncmp(gurl1.possibly_invalid_spec().data() + host1.begin,
gurl2.possibly_invalid_spec().data() + host2.begin,
host1.len);
}
このメソッド内の GetDomainAndRegistry(gurl1)
を起点に追っていくと、同ファイルの以下のコードに辿り着きます。
RegistryControlledDomainService::find_domain_function_ =
Perfect_Hash::FindDomain;
...
size_t RegistryControlledDomainService::GetRegistryLengthImpl(
const std::string& host,
bool allow_unknown_registries) {
DCHECK(!host.empty());
// Skip leading dots.
...
while (1) {
const char* domain_str = host.data() + curr_start;
int domain_length = host_check_len - curr_start;
const DomainRule* rule = find_domain_function_(domain_str, domain_length);
// We need to compare the string after finding a match because the
// no-collisions of perfect hashing only refers to items in the set. Since
// we're searching for arbitrary domains, there could be collisions.
if (rule &&
base::strncasecmp(domain_str, rule->name, domain_length) == 0) {
// Exception rules override wildcard rules when the domain is an exact
// match, but wildcards take precedence when there's a subdomain.
if (rule->type == kWildcardRule && (prev_start != std::string::npos)) {
// If prev_start == host_check_begin, then the host is the registry
// itself, so return 0.
return (prev_start == host_check_begin) ?
0 : (host.length() - prev_start);
}
if (rule->type == kExceptionRule) {
if (next_dot == std::string::npos) {
// If we get here, we had an exception rule with no dots (e.g.
// "!foo"). This would only be valid if we had a corresponding
// wildcard rule, which would have to be "*". But we explicitly
// disallow that case, so this kind of rule is invalid.
NOTREACHED() << "Invalid exception rule";
return 0;
}
return host.length() - next_dot - 1;
}
// If curr_start == host_check_begin, then the host is the registry
// itself, so return 0.
return (curr_start == host_check_begin) ?
0 : (host.length() - curr_start);
}
if (next_dot >= host_check_len) // Catches std::string::npos as well.
break;
prev_start = curr_start;
curr_start = next_dot + 1;
next_dot = host.find('.', curr_start);
}
...
ここまでを総合するとどうやら、現在アクセスしようとしている URL と、Cookie 発行元の URL をもとに、 Perfect_Hash::FindDomain
という関数で既知の suffix を利用してドメイン名を求め、その結果同士を比較して一致していれば Cookie を送信する、という処理をしているようです。
Perfect_Hash::FindDomain
の定義は net/base/registry_controlled_domains/effective_tld_names.cc
にあります。
/* C++ code produced by gperf version 3.0.3 */
/* Command-line: gperf -a -L C++ -C -c -o -t -k '*' -NFindDomain -D -m 5 effective_tld_names.gperf */
#if !((' ' == 32) && ('!' == 33) && ('"' == 34) && ('#' == 35) \
&& ('%' == 37) && ('&' == 38) && ('\'' == 39) && ('(' == 40) \
&& (')' == 41) && ('*' == 42) && ('+' == 43) && (',' == 44) \
...
このファイルは gperf
というツールによって、effective_tld_names.gperf
を元に生成されているようなのでそちらを見てみます。
...
co.je, 0
co.jp, 0
co.kr, 0
...
ありました。このファイルに5000個を超える数のドメイン名の suffix が列挙されています。
まとめ
既知の suffix から FQDN をドメイン名とホスト名に分解する部分等、細かい部分は省略しましたが、Chrome の Cookie のファーストパーティ/サードパーティの分類は以下のように行われているようです。
- Cookie 発行元 URL、Cookie 送信先(現在アクセスしようとしている) URL それぞれを、既知の suffix (ソース内に記述)にマッチさせる方法で、ドメイン名とホスト名に分割し、ドメイン名を比較する
例:
www.foo.co.jp
"jp" <- 既知の suffix と一致する
"co.jp" <- 既知の suffix と一致する
"foo.co.jp" <- 一致しない
-> "foo.co.jp" までがドメイン名
www2.foo.co.jp
"jp" <- 既知の suffix と一致する
"co.jp" <- 既知の suffix と一致する
"foo.co.jp" <- 一致しない
-> "foo.co.jp" までがドメイン名
www.hoge.jp
"jp" <- 既知の suffix と一致する
"hoge.jp" <- 一致しない
-> "hoge.jp" までがドメイン名
- そのドメイン名が一致するかどうかで、ファーストパーティ/サードパーティを区別する
Edge や FireFox も地獄を呼び寄せる予定だそうなので、そちらの実装も気になりますね。詳しい方いらっしゃいましたら教えてください。
追記
net/tools/tld_cleanup/README
によると、net/base/registry_controlled_domains/effective_tld_names.gperf
の生成元は net/base/registry_controlled_domains/effective_tld_names.dat
で、このリストは https://publicsuffix.org/list/ で公開されているもののようです。
RFC6265 HTTP State Management Mechanism(page23)にも以下のような記述がありました。
NOTE: A "public suffix" is a domain that is controlled by a
public registry, such as "com", "co.uk", and "pvt.k12.wy.us".
This step is essential for preventing attacker.com from
disrupting the integrity of example.com by setting a cookie
with a Domain attribute of "com". Unfortunately, the set of
public suffixes (also known as "registry controlled domains")
changes over time. If feasible, user agents SHOULD use an
up-to-date public suffix list, such as the one maintained by
the Mozilla project at http://publicsuffix.org/.