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


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ですが、受理されるでしょうか?

  1. Set-Cookieヘッダの内容について、最初の;までを取り出す。もし;が見つからない場合は最後まで取り出す。これをname-value-pairとする。取り出した残りの文字列はunparsed-attributesとする。
    • name-value-pair := "Cookie = IsYummy"
    • unparsed-attributes := "; Max-Age = 810"
  2. name-value-pairに=が見つからなかった場合、そのSet-Cookieを無視する。
  3. name-value-pairの最初の=までをname-stringとする。最初の=より後をvalue-stringとする。このときはどちらも空文字列であってもOK。
    • name-string := "Cookie "
    • value-string := " IsYummy"
  4. name-stringとvalue-stringのそれぞれから、先頭・末尾にある空白文字を削除する。
    • name-string <- "Cookie"
    • value-string <- "IsYummy"
  5. ここでname-stringが空白になった場合、そのSet-Cookieを無視する。
  6. name-stringがこのCookieの名前で、value-stringのこのCookieの値である。
  7. unparsed-attributesが空白の場合、ここで終了。
  8. unparsed-attributesの最初の文字列を捨てる。これは必ず;であるはず。
    • unparsed-attributes <- " Max-Age = 810"
  9. unparsed-attributesについて、最初の;までを取り出す。もし;が見つからない場合は最後まで取り出す。これをcookie-avとする。
    • unparsed-attributes <- ""
    • cookie-av := " Max-Age = 810"
  10. cookie-avの最初の=までをattribute-nameとする。最初の=より後をattribute-valueとする。このときはどちらも空文字列であってもOK。もし=が見つからない場合はcookie-avの全体をattribute-nameとする。
    • attribute-name := " Max-Age "
    • attribute-value := " 810"
  11. attribute-nameとattribute-valueのそれぞれから、先頭・末尾にある空白文字を削除する。
    • attribute-name <- "Max-Age"
    • attribute-value <- "810"
  12. 5.2.x.節の内容に従ってattributeを処理する。
    • 省略
  13. cookie-attribute-listの末尾にattribute-nameとattribute-valueのペアを追加する。
    • cookie-attribute-list := [ ("Max-Age", "810") ]
  14. 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は、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版ではちゃんと修正され、他の属性同様に最後の要素を使うようになりました。
実際にブラウザで試しても最後の要素が選択されます。


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

  2. https://serverfault.com/questions/849888/add-samesite-to-cookies-using-nginx-as-reverse-proxy