Cookieの扱いは地雷原
(grayduck.mn)- HTTP CookieはWebの状態を維持する基本的な仕組みだが、ブラウザ・サーバー・標準ライブラリの間で許可文字やエラー処理が食い違い、実際の障害につながることがある
- RFC 6265系では、サーバーが送る
Set-Cookie値とブラウザが受け入れる値の条件が異なり、document.cookieで作られた値がサーバー側パーサーの前提と衝突する - Firefox、Chromium、Safariは空白・引用符・カンマ・バックスラッシュ・Unicodeの扱いがそれぞれ異なり、Safariは禁則文字に遭遇してもCookie全体ではなくその手前までを保存する挙動を見せる
- Goはブラウザが許可したJSON Cookieを黙って欠落させることがあり、Pythonの
SimpleCookieは理解できないCookie以降の読み込みを停止することがある。PHP・Ruby・Rustも許容範囲がそれぞれ異なる - Unicode Cookie 1つでFacebook、Netflix、Okta、WhatsApp、AWS、Apple Supportなど主要サイトの400/500エラーや部分的な障害を引き起こし得るため、Cookie仕様とライブラリの挙動をより明確に揃える必要がある
ブラウザは受け入れるがGoは読めないCookie
- CookieはJavaScriptの
document.cookieやHTTPサーバーが設定するデータであり、有効期限まではスコープが一致するHTTPリクエストに継続して含まれる - 例のJavaScriptはJSON文字列をそのままセッションCookieの値として保存する
- 値は
{"ginger":"snap","peanutButter":"chocolate chip","snicker":"doodle"}という形式 - JSONをCookieに入れる際はbase64シリアライズを行うことが多いが、ブラウザはこの値を問題なく設定し、
Cookieヘッダーとして送信する
- 値は
- このCookieがGo標準ライブラリを使うコードに渡されることで問題が発生する
- GoのパーサーはこのCookieを解釈できない
- 失敗がスタックの上位へ連鎖的に伝播する
RFC内の2つの基準がずれている
- CookieはRFC 2109、RFC 2965、RFC 6265を経て定義され、現在更新中のdraft versionがある
- RFCはCookie値を2つの領域で異なる扱いにしている
- Section 4.1.1は、サーバーが
Set-Cookieで送る値から制御文字、空白、二重引用符、カンマ、セミコロン、バックスラッシュなどを除外する - Section 5.6は、ブラウザが
Set-Cookie文字列をパースする際、制御文字を除けばはるかに広く受け入れるようにしている
- Section 4.1.1は、サーバーが
- 中核的な衝突は、サーバーが送るべき値とブラウザが受け入れるべき値が整合していない点にある
- ブラウザがサーバー自身の設定したCookieだけを受け入れるなら影響は小さいが、
document.cookieでもCookieを作れる - 標準は、
Cookieヘッダーを処理する標準ライブラリがユーザーエージェントのように寛容であるべきか、サーバーのように厳格であるべきかを明確に定めていない
- ブラウザがサーバー自身の設定したCookieだけを受け入れるなら影響は小さいが、
ブラウザごとのCookie値の許容差
-
Firefox
- FirefoxのCookie値チェックは、RFC 6265で禁止された文字の一部を許可している
- 許可されるRFC推奨の除外文字は次のとおり
0x09horizontal tab0x20空白0x22二重引用符0x2Cカンマ0x5Cバックスラッシュ
- この挙動は過去にChromeとの互換性を合わせるために入ったもので、両方のコードベースに残っている
network.cookie.blockUnicode設定は0x80以上の値を拒否でき、関連作業はbug 1797231で追跡されている0x7Fを許可する問題はbug 1797235でFirefox 108に修正された
-
Chromium
- ChromiumはCookie値で制御文字とセミコロンだけを拒否する
- Firefoxよりわずかに厳格で、
0x09horizontal tabは受け入れない - RFCとは異なり、空白、二重引用符、カンマ、バックスラッシュ、Unicode文字を受け入れて再送信できる
-
Safari / WebKit
- SafariのCookie保存コードはクローズドソースの
CFNetwork内部にあるため、直接確認しにくい - JavaScriptで
0x00から0xFFまでのCookie値を設定して確認した結果、Safariは次の値を許可する0x09horizontal tab0x20空白0x22二重引用符0x5Cバックスラッシュ
- Safariは
0x7Fdeleteと0x80-FFhigh ASCII / Unicode文字を許可しない - RFCは制御文字に遭遇したらCookie全体を無視せよとしているが、Safariは禁則文字に遭遇した地点より前までの値を受け入れる
-- , --という値を設定すると、カンマ周辺の空白を削除するSafariのバグも観察されている
- SafariのCookie保存コードはクローズドソースの
言語と標準ライブラリのパース差
-
Go
- GoのCookieコードは、サーバーが
Set-Cookieで送る値に関するRFCの文言に比較的近く動作する - 実利用でよくある空白とカンマは許可するが、二重引用符・セミコロン・バックスラッシュは許可しない
- 例の
CookieヘッダーにJSON Cookieが含まれると、Goのrequest.Cookies()の結果にはcookie1=fooとcookie3=barだけが残る - ブラウザが受け入れる
cookie2は、例外や明示的なエラーなしに黙って抜け落ちる
- GoのCookieコードは、サーバーが
-
PHP
- PHPにはネイティブのCookieパース関数がないため正確な許容範囲は断定しにくいが、テスト結果では制御文字の扱いが一貫していない
0x00-0x09や0x0Dcarriage returnのような値は動作する0x10data link escapeや0x7Fdeleteを使うと、PHPは400 Bad Requestエラーを出す- Unicode Cookieもテスト出力に現れる
-
Python
- Pythonの
http.cookies.SimpleCookieはJSON Cookieに遭遇すると、それ以降のCookie読み込みを黙って停止する - 例の入力では、出力は
cookie1=fooだけが残る - サブドメインが親ドメインに問題のあるCookieを設定できるなら、そのCookie 1つがサイト全体のCookie処理を壊す可能性がある
- 制御文字の扱いも不規則
- 一部の制御文字は空の値として読み込まれる
- 値の前後に
aaを付けると、制御文字Cookieは読み込まれない
- Pythonの
-
Ruby
- Rubyの
CGI::Cookie.parseは、パース時に非常に寛容に動作するように見える - 制御文字、タブ、二重引用符、カンマ、バックスラッシュ、
0x7F、Unicode文字を受け入れ、Cookie jarから取り出す際にpercent-encodingを適用する - この方式はCookieの世界では最適に近いかもしれないが、
document.cookieで設定したコードがpercent-encodingされた反射値を期待していない可能性もある
- Rubyの
-
Rust
- Rustは標準のCookie処理機能を提供していないため、人気のある
cookiecrateを基準に確認した - デフォルト設定の
cookiecrateは最も寛容な側に近く、渡されたUTF-8文字列を受け入れるように見える
- Rustは標準のCookie処理機能を提供していないため、人気のある
実際のWebサイトで明らかになった影響
- テストサイトでサードパーティライブラリの更新を手動検証していた最中に、この問題が発見された
- 自動テストでは検出しにくい変更だった
- そのままデプロイされていれば、その後の訪問者が壊れたCookieを受け取り、更新のロールバックとCookie削除が行われるまで原因不明のエラーで締め出される可能性があった
- この問題は小規模サイトや特定のフレームワークだけに限定されない
- ブラウザコンソールで次のようにUnicode Cookieをドメインに設定すると、複数の主要サイトが壊れる可能性がある
document.cookie="unicodeCookie=🍪; domain=.grayduck.mn; Path=/; SameSite=Lax"
- 観察された事例は次のとおり
- Facebook: エラーページが表示され、画像も壊れる
- InstagramおよびThreads: 単純な500エラーが発生する
- Netflix:
NSES-500エラーを返し、ヘルプページも壊れる - Okta: すべてのログインページが400エラーを返す
- WhatsApp: “whatsapp error”が表示される
- Amazon: 大部分は動作するが、一部機能がランダムに壊れる
- AWS: ログインコンソールが400エラーを返して停止する
- Apple Support: デバイス一覧を読み込めない
- Best Buy: ナビゲーションは動作するが検索機能が動作しない
- eBay: 大部分は修正されたが、一部はいまだに400エラーを出す
- Home Depot: 修正予定
- Intuit: エラー原因を特定した唯一のサイト
- Outlook: さらに別の400エラー事例が現れる
標準と互換性の間にある修正の難しさ
- 30年前の基盤仕様の問題を直すのは非常に難しく、この問題に良い解決策はない可能性が高い
- ブラウザ側でこのようなCookieをブロックする案は、MozillaとGoogleの双方が検討し作業している
- Mozilla: bug 1797235、CVE-2023-5723、bug 1797231
- Google: bug 40061459
- 一方的なブロックは互換性問題のため複雑
- 非ASCII Cookieは全Cookieの0.01%未満の水準で、一般的ではない
- Argentina、Mexico、Finlandのような国でははるかに頻繁に現れるというtelemetryがある
- Mozillaはすぐ有効化できる
network.cookie.blockUnicode設定を実装したが、Chromiumとの挙動互換性の問題から有効化していない
- サーバー側での修正も可能かもしれないが、数百万のWebサイトと、言語・フレームワーク内部のエラー処理にまたがっている
- FacebookやNetflixのようなところは緩和できるとしても、平均的なサイト運営者が解決する時間や能力を持つのは難しい
- 根本的な解決は、IETF HTTP Working GroupがCookie仕様を内部的に整合させ、Cookie処理システムがどう動作すべきかを厳格に定めることにある
- 非ASCII文字を許可するかどうかは、サーバー側とユーザーエージェントで同一であるべき
- ブラウザ、言語、フレームワークがCookieを処理する段階も、Content Security Policyのような現代的なW3C標準のように明示的であるべき
- 不正なCookie 1つのせいで他のCookie処理まで停止する挙動は、さまざまな予期しない障害につながり得るため受け入れがたい
提案されたCookie処理手順
field-valueから始め、;と,で分割してraw-cookie-pairのリストを作る。ただし、カンマをセミコロンの同義語として扱わない- 各
raw-cookie-pairは次の順序で処理する=がなければ次のpairへ進む- 前後の空白を除去する
- 最初の
=より前をcookie-name-octets、後ろをcookie-value-octetsとして扱う - 値が二重引用符で始まる場合は先頭の二重引用符を1つ削除し、末尾に二重引用符があれば1つ削除する
- 名前または値がサーバーで受け入れられない形式なら、そのpairをスキップする
- 残った
[cookie-name-octets, cookie-value-octets]タプルはサーバー定義の方式で処理する
- サーバーはさらに、Cookie名がtokenでないタプルを拒否し、Cookie値が
cookie-octetにないoctetを含む場合は拒否する方向が提案されている
1件のコメント
Hacker Newsの意見
Cookieは奇妙な落とし穴や扱いにくい挙動でいっぱいだが、99.95%はうまく動く。いちばん好きなCookieの地雷原は Cookieシャドーイング(cookie shadowing) で、同じ名前でドメインやパスのような主要属性だけを変えてCookieを設定すると、ほぼ同じCookieが複数同時にでき、バックエンドやJSからはどれがどれなのか区別する方法がない。
https://example.com/somepathに行って、ブラウザコンソールで以下を入力してみればよい。
document.cookie = "foo=a";document.cookie = "foo=b; domain=.example.com";document.cookie = "foo=c; path=/somepath";document.cookie自分の場合、結果は
'foo=c; foo=a; foo=b'だった。本当にとんでもないミスだ。
/somepathにいるなら、3つの値のうち最も具体的な値であるCを受け取るのはかなり合理的に見える。すべての値が順番に返されるので、パス別の値とグローバル値の両方を知ることができ、最善の妥協のように感じられる。ただし、魔法のような**
document.cookieセッター**は気に入らないが、もうほぼ30年ものなので仕方がない。最近jshttp/cookieで検証を強化したことで、この問題が再び浮上した: https://github.com/jshttp/cookie/pull/167
そのPR以降、検証は記事で言及されているブラウザコードに近い形で、少し緩められた。
もともとの変更は、私たちのコードで、エンコードせずに文字列を連結してCookieヘッダーを作っていたバグを見つけたことから始まった。時々値に空白が入り、リクエストが壊れていた。これを避けるために開発者にjshttp/cookieの
serialize()使用を提案しようとしたが、その関数の検証では私たちが見たバグを捕まえるには不十分だと分かった。修正案を提案すると、別の人が検証が緩すぎてCookieの名前フィールドにJSを差し込み、別の場所でそれが値のように解釈されるようにできることを発見した。かなり珍しいコードインジェクション経路になった。
記事ではRustのアプローチに触れているが、他の言語と違ってRust標準ライブラリにはCookie処理機能は入っていない。実際にはサードパーティの**
cookieクレート**の挙動を見ていることになり、Rubyのようにパーセントエンコードするオプションも含まれている: https://docs.rs/cookie/0.18.1/cookie/HTTPプロトコルの中には、実質的に互いに異なるプロトコルが1万個ほど埋め込まれているように思う。ブラウザとWebサーバーがあらゆる機能を継ぎ足し、それぞれに仕様と事実上の仕様があり、そのすべてがほぼ1つの汎用的なHTTPという傘の下で運ばれている。
クライアントがこの1万個の非仕様のうちどのバージョンと互換性があるのかを指定することもできず、サーバーも同じだ。仕様をアップグレードできない理由は、他のクライアントが理解できず、後方互換性もないからだ。
その結果、誰も合意できず、直すこともできないランダムな混沌が残った。計画的な廃止もないので、過去の悪い決定を引きずり続けなければならない。
約10年前のプロジェクトでCookieベースのセッションを実装したが、認証がSafariでは動いてChromeでは動かない理由をデバッグするのに本当に苦労した。正確にどちらだったかは覚えていないが、片方のブラウザは形式が合っていないとCookieをまったく設定しなかった。
特別変なことをしたわけでもなく、記憶では
-と_の違いだった気がする。Set-Cookieヘッダー**だったかもしれない。以前この問題のせいで、Cookieキーに
camelCaseを使えなかったことがある。検索しても正確な issue はうまく見つけられない。
Cookieが導入された直後から、合理的な使い方は不透明トークンだけを入れて、サーバーが次回同じクライアントだと分かるようにし、残りはすべてサーバー側に保存することだと考えられていたように思う。
クライアントが原理的にサーバーが絶対に送らない値を処理できることが、なぜ問題なのか分からない。単にそういう値を送らなければよく、「それを送ったら何が起きるのか?」のような謎を心配する必要もない。
それでも不透明トークンを保存する唯一の場所なので、認証には使う必要がある。
Cookieヘッダーのパースはめちゃくちゃです。「標準」は実際の現場に存在する挙動を反映できておらず、バックエンドサーバー、ライブラリ、フレームワークごとに受け入れる形式が違い、ブラウザはまた別のことをします
フロントエンドとバックエンドを完全に制御しているなら大きな問題ではありませんが、異なるもの同士を連携させる必要が出た瞬間に、あっという間にばかげた状況になります
Cookieは大きく複雑な混沌に見える一方で、後方互換性のためにほとんど変更も不可能です。こういうときは、完全に別の新しい仕組みを作るのが正しいのではないかと思います
たとえば NewCookie のような仕組みを新たに仕様化し、最初から一貫して動作するように設計し直せます。現代的なセキュリティ対策を組み込み、より厳密な仕様ときちんとしたUnicodeサポートも入れられます
Set-Cookie2ヘッダーがあります: https://stackoverflow.com/q/9462180/3474615少なくとも一部のユースケースではそうで、もちろんヘッダーと直接統合されているわけではありません
Cookieがすでに存在しているため、私たちはCookieに縛られています
iOS Safariが、顧客が管理するドメインのCookieを勝手に食い潰す問題を追いかけるのに、丸々1か月を費やしました。Google、Twitter、Facebookのようなドメインでセッション状態がこのように消えるのを見たことはありません
もう少し真面目に言うと、cookieという単語を避けて、まったく別の名前を付けるのがよいでしょう。cookieという言葉には、あまりに多くの重荷が付いています
筆者が
JSON.stringifyの結果をCookieに入れるところから始めていたので、文字列化されるJSONの中に誰かがセミコロンを入れたせいではなかったのが、むしろ意外でしたCookie周りの厄介事の大半は、任意のユーザー入力をCookieに入れようとするときに起きるように思います。そうすべきではありません。認証トークンで使うような固定長の英数字ASCII文字列だけを使っていれば大丈夫です
かなり地雷原だという点には同意します
開発者としての回避策は、値を URLセーフBase64 でエンコードすることです。そうすれば生のバイト値が得られ、内部表現は好きなように使えます。ただし記事でも述べているように、100%制御できるわけではありません。ユーザーエージェントなので、そうあるべきでもあります
より多くのユーザーエージェントが、「ワイヤ上のバイト列と祈り」よりも標準準拠を選んでくれるといいのですが。スクリーンショットの400レスポンスは仕様に沿った応答です。ヘッダーが最初からUTF-8だったか、最初はASCIIで後からUTF-8を許可するほうがよかったように思います。ただし前者は因果関係上難しく、後者ももともと違法だった値を合法にするため、なお問題を引き起こし得ます
base64urlエンコーディングは、base64にURLエンコードを加えたものと約3%のケースで互換性がなく、開発中は見落としやすいものの、本番では必ず爆発します=、/、+文字を入れられるので、標準の Base64エンコーディング も使えます :)記事はポステルの法則をあざ笑っていますが、Cookieを設定する側が送信時に保守的であったなら、そもそもこのような記事は必要なかったはずです
ときにその地雷は単なるバグではなく、大きなセキュリティホールにもなります
クライアントが仕様に合わないデータを送るなら、それはバグであり修正すべきです。サーバーが意図を推測して受け入れることが、決して当たり前になってはいけません