Webには状態を管理する方法の1つとして、**Cookie(クッキー)**があるのはご存知かと思います。
このCookieは、サーバーから送信されるSet-Cookie
と呼ばれるHTTPレスポンスヘッダを用いてクライアント側に保存されるものです。
ここでは、Set-Cookie
が不正な文字列、もしくはその解釈が自明でないような文字列であった場合、クライアント(Webブラウザ)がどのように振る舞うのかをRFC6265をナナメ読みしつつ追っていきます。
Cookieの仕様
Cookieに関する仕様はRFC6265 - HTTP State Management Mechanismに定められています。
Set-Cookieの文法
「Set-Cookie
が不正な文字列の場合」と言いましたが、そもそもSet-Cookieがどういう文字列であればValidであり、またはInvalidなんでしょうか?
これは4.1.1. Syntaxに書かれています。
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と呼ばれるものです。
記法の意味は👆のリンク先を参照してください。
コレに従うとすると、以下のようなSet-Cookie
はValidで……
Set-Cookie: Cookie=IsYummy; Secure
一方で、例えば以下のようなものはInvalidです。1
# 名前が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では、各cookie-av(後ろにくっついているPath=/
のような属性のこと)の意味が定義されています。
ここで1つ気になるのは、例えば以下のようなSet-Cookie
はどのように解釈されるのか?という点です。
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では、Set-Cookie
を受け取ったUser Agent(ブラウザ)がどのようにCookieを処理すべきが具体的に書かれています。
注目すべきなのは、ここで定義されている処理法がだいぶPermissiveであり、4.1.1. Syntaxで示されたSyntaxを満たさないSet-Cookie
であっても受理できる点です。
詳しくはRFC6265の5.2.を参照していただきたいのですが、ざっくり書いておくと以下のような流れです。
例えばSet-Cookie: Cookie = IsYummy; Max-Age = 810
を受け取った場合の動作も書き添えておきます。これは無駄なSPが入っているためInvalidなSyntaxですが、受理されるでしょうか?
- Set-Cookieヘッダの内容について、最初の
;
までを取り出す。もし;
が見つからない場合は最後まで取り出す。これをname-value-pairとする。取り出した残りの文字列はunparsed-attributesとする。name-value-pair := "Cookie = IsYummy"
unparsed-attributes := "; Max-Age = 810"
- name-value-pairに
=
が見つからなかった場合、そのSet-Cookieを無視する。 - name-value-pairの最初の
=
までをname-stringとする。最初の=
より後をvalue-stringとする。このときはどちらも空文字列であってもOK。name-string := "Cookie "
value-string := " IsYummy"
- name-stringとvalue-stringのそれぞれから、先頭・末尾にある空白文字を削除する。
name-string <- "Cookie"
value-string <- "IsYummy"
- ここでname-stringが空白になった場合、そのSet-Cookieを無視する。
- name-stringがこのCookieの名前で、value-stringのこのCookieの値である。
- unparsed-attributesが空白の場合、ここで終了。
- unparsed-attributesの最初の文字列を捨てる。これは必ず
;
であるはず。unparsed-attributes <- " Max-Age = 810"
- unparsed-attributesについて、最初の
;
までを取り出す。もし;
が見つからない場合は最後まで取り出す。これをcookie-avとする。unparsed-attributes <- ""
cookie-av := " Max-Age = 810"
- cookie-avの最初の
=
までをattribute-nameとする。最初の=
より後をattribute-valueとする。このときはどちらも空文字列であってもOK。もし=
が見つからない場合はcookie-avの全体をattribute-nameとする。attribute-name := " Max-Age "
attribute-value := " 810"
- attribute-nameとattribute-valueのそれぞれから、先頭・末尾にある空白文字を削除する。
attribute-name <- "Max-Age"
attribute-value <- "810"
- 5.2.x.節の内容に従ってattributeを処理する。
省略
- cookie-attribute-listの末尾にattribute-nameとattribute-valueのペアを追加する。
cookie-attribute-list := [ ("Max-Age", "810") ]
- Step.7から繰り返し。
戻ってすぐ終了
このようにSyntaxに従わないSet-Cookie
でも、ある程度は受理できそうなことがわかります。
ストレージモデル
5.3. Storage Modelでは、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の最後の要素を使うように書かれています。
cookie-attribute-listは、Set-Cookie
のパース中は必ず末尾に要素が追加されていくものでした。
つまり、Set-Cookie
に同じ属性があった場合、後に出てきた要素が優先されるのが正解です。
実際のブラウザの挙動
Set-Cookie
が不正、もしくは解釈が自明でない場合の動作は、RFC6265を読むことで大方見えてきました。
では、世の中のブラウザは実際どのように振る舞うのかを見ていきましょう。
ブラウザの実装をソースコードから探す気力はなかったので、実際に変なSet-Cookie
を投げつけて挙動を確認することにします。。。
以下のようなPHPコードをつかって、ダメなSet-Cookie
をブラウザに食わせてみました。
<?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 | 無視 | 受理 | 受理 | 無視 |
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
Chrome 80
Safari 13
どんな場合にSet-Cookieが不正な文字列、もしくは解釈が自明でない文字列になるか
私が遭遇したシチュエーションが以下です。
- nginxをリバースプロキシとして用いている
- Chrome 80から
SameSite
属性が未指定の場合、デフォルトでSameSite=Lax
として取り扱われることになった - アプリが想定外の挙動をしたので、従来の挙動
SameSite=None
に戻したい - しかしアプリには手を加えたくない
- ので、nginxでCookieを書き換えて
SameSite=None
を追加することにした
nginxでこれをやろうとしたとき、proxy_cookie_path directiveを使う方法があります。
これは、その名前の通りCookieのPath属性を書き換えるものなのですが、実は以下のように指定すると別の属性を追加することができる2という、だいぶ怪しい使い方があります。
proxy_cookie_path / "/; SameSite=strict";
これを導入すると、とりあえず目的は達成されるのですが、全てのCookieに同じSameSite
をつけることしかできませんし、あとから気が変わってアプリ側でSameSite
を付与することになった場合、SameSite
属性が重複することになってしまうのです。。。
ちなみに
SameSite
は比較的新しいCookieの仕様で、RFC6265にはまだ含まれていません。
新しい仕様の草稿draft-ietf-httpbis-rfc6265bisに含まれています。
このSameSite
が重複していたときの取り扱いについて、rfc6265bisの02版ではcookie-attribute-listの最後の要素を使う旨の文が抜けており、曖昧性を残していて気持ちが悪かったのですが、04版ではちゃんと修正され、他の属性同様に最後の要素を使うようになりました。
実際にブラウザで試しても最後の要素が選択されます。
-
この文法定義は曖昧性を残しており、たとえば最後の例は extension-av であるとみなすならばValidです。 ↩
-
https://serverfault.com/questions/849888/add-samesite-to-cookies-using-nginx-as-reverse-proxy ↩