Set-Cookieが不正な文字列、もしくは解釈が自明でない場合の挙動について

Webには**状態**を管理する方法の1つとして、**Cookie（クッキー）**があるのはご存知かと思います。
このCookieは、サーバーから送信される`Set-Cookie`と呼ばれるHTTPレスポンスヘッダを用いてクライアント側に保存されるものです。

ここでは、`Set-Cookie`が不正な文字列、もしくはその解釈が自明でないような文字列であった場合、クライアント（Webブラウザ）がどのように振る舞うのかを[RFC6265](https://tools.ietf.org/html/rfc6265)をナナメ読みしつつ追っていきます。

# Cookieの仕様

Cookieに関する仕様は[RFC6265 - HTTP State Management Mechanism](https://tools.ietf.org/html/rfc6265)に定められています。

## Set-Cookieの文法

「`Set-Cookie`が不正な文字列の場合」と言いましたが、そもそもSet-Cookieがどういう文字列であればValidであり、またはInvalidなんでしょうか？
これは[4.1.1. Syntax](https://tools.ietf.org/html/rfc6265#section-4.1.1)に書かれています。

```ini
 set-cookie-header = "Set-Cookie:" SP set-cookie-string
 set-cookie-string = cookie-pair *( ";" SP cookie-av )
 cookie-pair       = cookie-name "=" cookie-value
 cookie-name       = token
 cookie-value      = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
 cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
                       ; US-ASCII characters excluding CTLs,
                       ; whitespace DQUOTE, comma, semicolon,
                       ; and backslash
 token             = <token, defined in [RFC2616], Section 2.2>

 cookie-av         = expires-av / max-age-av / domain-av /
                     path-av / secure-av / httponly-av /
                     extension-av
 expires-av        = "Expires=" sane-cookie-date
 sane-cookie-date  = <rfc1123-date, defined in [RFC2616], Section 3.3.1>
 max-age-av        = "Max-Age=" non-zero-digit *DIGIT
                       ; In practice, both expires-av and max-age-av
                       ; are limited to dates representable by the
                       ; user agent.
 non-zero-digit    = %x31-39
                       ; digits 1 through 9
 domain-av         = "Domain=" domain-value
 domain-value      = <subdomain>
                       ; defined in [RFC1034], Section 3.5, as
                       ; enhanced by [RFC1123], Section 2.1
 path-av           = "Path=" path-value
 path-value        = <any CHAR except CTLs or ";">
 secure-av         = "Secure"
 httponly-av       = "HttpOnly"
 extension-av      = <any CHAR except CTLs or ";">
```

これは[ABNF](https://ja.wikipedia.org/wiki/ABNF)と呼ばれるものです。
記法の意味は👆のリンク先を参照してください。

コレに従うとすると、以下のような`Set-Cookie`はValidで……

```yaml
Set-Cookie: Cookie=IsYummy; Secure
```

一方で、例えば以下のようなものはInvalidです。[^inv]

[^inv]: この文法定義は曖昧性を残しており、たとえば最後の例は extension-av であるとみなすならばValidです。

```yaml
# 名前がRFC2616のtoken定義を満たしていない（separatorsであるカッコを用いることはできない）
Set-Cookie: Co(o)kie=IsYummy

# *cookie-octet に whitespace は含まれない（たとえDQUOTEで囲われていても）
Set-Cookie: Cookie=is yummy
Set-Cookie: Cookie="is yummy"

# 必ず ; の後に SP が必要
Set-Cookie: Cookie=IsYummy;Secure

# 末尾の ; が余計
Set-Cookie: Cookie=IsYummy; HttpOnly;

# Max-Age の1文字目は 0 であってはならない
Set-Cookie: Cookie=IsYummy; Max−Age=01
```

そもそも、RFC6265の本節には「文法を満たさない`Set-Cookie`ヘッダを送るべきではない（SHOULD NOT）」と書かれていますが、様々な理由によって不正な`Set-Cookie`が送信されてしまうことがあります。
実際の例については、本記事の末尾で触れます。

## Set-CookieのSemantics

[4.1.2. Semantics](https://tools.ietf.org/html/rfc6265#section-4.1.2)では、各cookie-av（後ろにくっついている`Path=/`のような属性のこと）の意味が定義されています。

ここで1つ気になるのは、例えば以下のような`Set-Cookie`はどのように解釈されるのか？という点です。

```yaml
Set-Cookie: Cookie=IsYummy; Max−Age=123; Max-Age=456
Set-Cookie: Cookie=IsYummy; Path=/abc; Path=/def
```

これらは、Syntax的にはValidなものですが、ブラウザがどのように解釈するべきなのか自明ではありません。

## Set-Cookieのパース

[5.2. The Set-Cookie Header](https://tools.ietf.org/html/rfc6265#section-5.2)では、`Set-Cookie`を受け取ったUser Agent（ブラウザ）がどのようにCookieを処理すべきが具体的に書かれています。

注目すべきなのは、ここで定義されている処理法がだいぶPermissiveであり、[4.1.1. Syntax](https://tools.ietf.org/html/rfc6265#section-4.1.1)で示されたSyntaxを満たさない`Set-Cookie`であっても受理できる点です。

詳しくはRFC6265の5.2.を参照していただきたいのですが、ざっくり書いておくと以下のような流れです。
例えば`Set-Cookie: Cookie = IsYummy; Max-Age = 810`を受け取った場合の動作も書き添えておきます。これは無駄なSPが入っているためInvalidなSyntaxですが、受理されるでしょうか？

1. Set-Cookieヘッダの内容について、最初の`;`までを取り出す。もし`;`が見つからない場合は最後まで取り出す。これをname-value-pairとする。取り出した残りの文字列はunparsed-attributesとする。
	- `name-value-pair := "Cookie = IsYummy"`
	- `unparsed-attributes := "; Max-Age = 810"`
1. name-value-pairに`=`が見つからなかった場合、そのSet-Cookieを無視する。
1. name-value-pairの最初の`=`までをname-stringとする。最初の`=`より後をvalue-stringとする。このときはどちらも空文字列であってもOK。
	- `name-string := "Cookie "`
	- `value-string := " IsYummy"`
1. name-stringとvalue-stringのそれぞれから、先頭・末尾にある空白文字を削除する。
	- `name-string <- "Cookie"`
	- `value-string <- "IsYummy"`
1. ここでname-stringが空白になった場合、そのSet-Cookieを無視する。
1. name-stringがこのCookieの名前で、value-stringのこのCookieの値である。
7. unparsed-attributesが空白の場合、ここで終了。
1. unparsed-attributesの最初の文字列を捨てる。これは必ず`;`であるはず。
	- `unparsed-attributes <- " Max-Age = 810"`
1. unparsed-attributesについて、最初の`;`までを取り出す。もし`;`が見つからない場合は最後まで取り出す。これをcookie-avとする。
	- `unparsed-attributes <- ""`
	- `cookie-av := " Max-Age = 810"`
1. cookie-avの最初の`=`までをattribute-nameとする。最初の`=`より後をattribute-valueとする。このときはどちらも空文字列であってもOK。もし`=`が見つからない場合はcookie-avの全体をattribute-nameとする。
	- `attribute-name := " Max-Age "`
	- `attribute-value := " 810"`
1. attribute-nameとattribute-valueのそれぞれから、先頭・末尾にある空白文字を削除する。
	- `attribute-name <- "Max-Age"`
	- `attribute-value <- "810"`
1. 5.2.x.節の内容に従ってattributeを処理する。
	- `省略`
1. cookie-attribute-listの末尾にattribute-nameとattribute-valueのペアを追加する。
	- `cookie-attribute-list := [ ("Max-Age", "810") ]`
1. Step.7から繰り返し。
	- `戻ってすぐ終了`

このようにSyntaxに従わない`Set-Cookie`でも、ある程度は受理できそうなことがわかります。

## ストレージモデル

[5.3. Storage Model](https://tools.ietf.org/html/rfc6265#section-5.3)では、Cookieを受理したブラウザがどのようにCookieを保管すべきかが書かれています。

先述した**どのように解釈するべきなのか自明でないCookie**ですが、本節を読むとこの解釈の答えが見つかります。
たとえば、`Max-Age`が複数あった場合の解釈はどうすべきなのかを要点だけ抜き出して書くと、以下の通りになります。

- まず、保存されるCookieはexpiry-timeという属性を持つ
- もしcookie-attribute-listに`Max-Age`というattribute-nameを持つ要素が含まれていたなならば……
	- cookie-attribute-listで`Max-Age`というattribute-nameを持つ**最後の要素**のattribute-valueをCookieのexpiry-timeへ設定する

他の属性についても、基本的にはこのようにcookie-attribute-listの**最後の要素**を使うように書かれています。

cookie-attribute-listは、`Set-Cookie`のパース中は必ず末尾に要素が追加されていくものでした。
つまり、`Set-Cookie`に同じ属性があった場合、**後に出てきた要素が優先される**のが正解です。

# 実際のブラウザの挙動

`Set-Cookie`が不正、もしくは解釈が自明でない場合の動作は、[RFC6265](https://tools.ietf.org/html/rfc6265)を読むことで大方見えてきました。
では、世の中のブラウザは実際どのように振る舞うのかを見ていきましょう。

ブラウザの実装をソースコードから探す気力はなかったので、実際に変な`Set-Cookie`を投げつけて挙動を確認することにします。。。

以下のようなPHPコードをつかって、ダメな`Set-Cookie`をブラウザに食わせてみました。

```php
<?php
	// Case 1: Syntaxが不正だが受理されるはず
	header("Set-Cookie: Inva(l)idToken=Inva(l)idToken", false);

	// Case 2: Syntaxが不正だが受理されるはず
	header("Set-Cookie: Has Whitespace=Has Whitespace", false);

	// Case 3: Syntaxが不正で無視されるはず
	header("Set-Cookie: =NoName", false);

	// Case 4: Syntaxが不正だが受理され、HttpOnlyが有効になるはず
	header("Set-Cookie: InvalidSyntax=InvalidSyntax;HttpOnly;", false);

	// Case 5: Path=/B になるはず
	header("Set-Cookie: MultiPath=MultiPath; Path=/A; Path=/B", false);
?>
```

## 結果

Firefox 74 と Chrome 80 と Safari 13 でそれぞれ実験しました。結果は以下の表の通りです。

| | RFC | Firefox 74 | Chrome 80 | Safari 13 |
| --- | --- | --- | --- | --- |
| Case 1 | 受理 | 受理 | 受理 | 受理 |
| Case 2 | 受理 | 受理 | 受理 | 受理 |
| Case 3 | 無視 | <font color="red">受理</font> | <font color="red">受理</font> | 無視 |
| Case 4 | 受理/HttpOnly | 受理/HttpOnly | 受理/HttpOnly | 受理/HttpOnly |
| Case 5 | Path=/B | Path=/B | Path=/B | Path=/B |

概ね想定通りでしたが、cookie-nameが空白のCookieの取り扱いについて、SafariはRFCに従っていたものの、FirefoxとChromeは無視されるべきものを受理していました。

以下はエビデンス画像です。

### Firefox 74

<img width="516" alt="firefox.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150934/56476447-7011-a91b-4f2b-fa68f374dbbe.png">

### Chrome 80

<img width="609" alt="chrome.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150934/b863b763-f8e9-ae7f-5a98-46fd83723111.png">

### Safari 13

<img width="653" alt="safari.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150934/33dae169-0a3a-420c-c47a-3ad0f139f5d2.png">

# どんな場合にSet-Cookieが不正な文字列、もしくは解釈が自明でない文字列になるか

私が遭遇したシチュエーションが以下です。

- nginxをリバースプロキシとして用いている
- Chrome 80から`SameSite`属性が未指定の場合、デフォルトで`SameSite=Lax`として取り扱われることになった
- アプリが想定外の挙動をしたので、従来の挙動`SameSite=None`に戻したい
- しかしアプリには手を加えたくない
- ので、nginxでCookieを書き換えて`SameSite=None`を追加することにした

nginxでこれをやろうとしたとき、[proxy_cookie_path directive](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cookie_path)を使う方法があります。
これは、その名前の通りCookieのPath属性を書き換えるものなのですが、実は以下のように指定すると別の属性を追加することができる[^src]という、だいぶ怪しい使い方があります。

[^src]: https://serverfault.com/questions/849888/add-samesite-to-cookies-using-nginx-as-reverse-proxy

```
proxy_cookie_path / "/; SameSite=strict";
```

これを導入すると、とりあえず目的は達成されるのですが、全てのCookieに同じ`SameSite`をつけることしかできませんし、あとから気が変わってアプリ側で`SameSite`を付与することになった場合、`SameSite`属性が重複することになってしまうのです。。。

## ちなみに

`SameSite`は比較的新しいCookieの仕様で、RFC6265にはまだ含まれていません。
新しい仕様の草稿[draft-ietf-httpbis-rfc6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)に含まれています。

この`SameSite`が重複していたときの取り扱いについて、rfc6265bisの[02版](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-02#section-5.4)では**cookie-attribute-listの最後の要素を使う旨の文が抜けて**おり、曖昧性を残していて気持ちが悪かったのですが、[04版](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-02#section-5.4)ではちゃんと修正され、他の属性同様に最後の要素を使うようになりました。
実際にブラウザで試しても最後の要素が選択されます。