Chrome の「サードパーティCookie」とは

[Chrome 80が密かに呼び寄せる地獄 ～ SameSite属性のデフォルト変更を調べてみた](https://qiita.com/ahera/items/0c8276da6b0bed2b580c)

巷では、今月リリースされた 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)は第２レベルドメインが一致するかどうかで判定されており、(3)(4)は第３レベルドメインで判定されているようです。(5)(6)は、階層は(3)(4)と同じなのに、第２レベルドメインで判定されています。

## 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``` というメソッドが定義されています。

```cpp:net/base/static_cookie_policy.cc
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``` にありました。

```cpp: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)``` を起点に追っていくと、同ファイルの以下のコードに辿り着きます。

```cpp:net/base/registry_controlled_domains/registry_controlled_domain.cc
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``` にあります。

```cpp: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``` を元に生成されているようなのでそちらを見てみます。

```net/base/registry_controlled_domains/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)](https://tools.ietf.org/html/rfc6265#page-23)にも以下のような記述がありました。

> 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/>.