3 ポイント 投稿者 GN⁺ 2024-11-22 | 1件のコメント | WhatsAppで共有
  • 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 2109RFC 2965RFC 6265を経て定義され、現在更新中のdraft versionがある
  • RFCはCookie値を2つの領域で異なる扱いにしている
    • Section 4.1.1は、サーバーがSet-Cookieで送る値から制御文字、空白、二重引用符、カンマ、セミコロン、バックスラッシュなどを除外する
    • Section 5.6は、ブラウザがSet-Cookie文字列をパースする際、制御文字を除けばはるかに広く受け入れるようにしている
  • 中核的な衝突は、サーバーが送るべき値ブラウザが受け入れるべき値が整合していない点にある
    • ブラウザがサーバー自身の設定したCookieだけを受け入れるなら影響は小さいが、document.cookieでもCookieを作れる
    • 標準は、Cookieヘッダーを処理する標準ライブラリがユーザーエージェントのように寛容であるべきか、サーバーのように厳格であるべきかを明確に定めていない

ブラウザごとのCookie値の許容差

  • Firefox

    • FirefoxのCookie値チェックは、RFC 6265で禁止された文字の一部を許可している
    • 許可されるRFC推奨の除外文字は次のとおり
      • 0x09 horizontal tab
      • 0x20 空白
      • 0x22 二重引用符
      • 0x2C カンマ
      • 0x5C バックスラッシュ
    • この挙動は過去にChromeとの互換性を合わせるために入ったもので、両方のコードベースに残っている
    • network.cookie.blockUnicode設定は0x80以上の値を拒否でき、関連作業はbug 1797231で追跡されている
    • 0x7Fを許可する問題はbug 1797235でFirefox 108に修正された
  • Chromium

    • ChromiumはCookie値で制御文字とセミコロンだけを拒否する
    • Firefoxよりわずかに厳格で、0x09 horizontal tabは受け入れない
    • RFCとは異なり、空白、二重引用符、カンマ、バックスラッシュ、Unicode文字を受け入れて再送信できる
  • Safari / WebKit

    • SafariのCookie保存コードはクローズドソースのCFNetwork内部にあるため、直接確認しにくい
    • JavaScriptで0x00から0xFFまでのCookie値を設定して確認した結果、Safariは次の値を許可する
      • 0x09 horizontal tab
      • 0x20 空白
      • 0x22 二重引用符
      • 0x5C バックスラッシュ
    • Safariは0x7F deleteと0x80-FF high ASCII / Unicode文字を許可しない
    • RFCは制御文字に遭遇したらCookie全体を無視せよとしているが、Safariは禁則文字に遭遇した地点より前までの値を受け入れる
    • -- , --という値を設定すると、カンマ周辺の空白を削除するSafariのバグも観察されている

言語と標準ライブラリのパース差

  • Go

    • GoのCookieコードは、サーバーがSet-Cookieで送る値に関するRFCの文言に比較的近く動作する
    • 実利用でよくある空白とカンマは許可するが、二重引用符・セミコロン・バックスラッシュは許可しない
    • 例のCookieヘッダーにJSON Cookieが含まれると、Goのrequest.Cookies()の結果にはcookie1=foocookie3=barだけが残る
    • ブラウザが受け入れるcookie2は、例外や明示的なエラーなしに黙って抜け落ちる
  • PHP

    • PHPにはネイティブのCookieパース関数がないため正確な許容範囲は断定しにくいが、テスト結果では制御文字の扱いが一貫していない
    • 0x00-0x090x0D carriage returnのような値は動作する
    • 0x10 data link escapeや0x7F deleteを使うと、PHPは400 Bad Requestエラーを出す
    • Unicode Cookieもテスト出力に現れる
  • Python

    • Pythonのhttp.cookies.SimpleCookieはJSON Cookieに遭遇すると、それ以降のCookie読み込みを黙って停止する
    • 例の入力では、出力はcookie1=fooだけが残る
    • サブドメインが親ドメインに問題のあるCookieを設定できるなら、そのCookie 1つがサイト全体のCookie処理を壊す可能性がある
    • 制御文字の扱いも不規則
      • 一部の制御文字は空の値として読み込まれる
      • 値の前後にaaを付けると、制御文字Cookieは読み込まれない
  • Ruby

    • RubyのCGI::Cookie.parseは、パース時に非常に寛容に動作するように見える
    • 制御文字、タブ、二重引用符、カンマ、バックスラッシュ、0x7F、Unicode文字を受け入れ、Cookie jarから取り出す際にpercent-encodingを適用する
    • この方式はCookieの世界では最適に近いかもしれないが、document.cookieで設定したコードがpercent-encodingされた反射値を期待していない可能性もある
  • Rust

    • Rustは標準のCookie処理機能を提供していないため、人気のあるcookie crateを基準に確認した
    • デフォルト設定のcookie crateは最も寛容な側に近く、渡されたUTF-8文字列を受け入れるように見える

実際の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の双方が検討し作業している
  • 一方的なブロックは互換性問題のため複雑
    • 非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件のコメント

 
GN⁺ 2024-11-22
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'だった。

    • 会社で誰が設計したのか分からないが、ステージング環境と開発環境を同じドメインに置いていて、巨大な会社全体がこのパターンに従っている。
      本当にとんでもないミスだ。
    • 同じブラウザで1つのWebサイトに複数アカウントを使うと起きる奇妙な挙動のかなりの部分は、これで説明できるかもしれないと思う。
    • /somepathにいるなら、3つの値のうち最も具体的な値であるCを受け取るのはかなり合理的に見える。すべての値が順番に返されるので、パス別の値とグローバル値の両方を知ることができ、最善の妥協のように感じられる。
      ただし、魔法のような**document.cookieセッター**は気に入らないが、もうほぼ30年ものなので仕方がない。
    • 参考までに、技術的にはドメイン前のドットは許可されておらず、無視される: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3
      最近jshttp/cookieで検証を強化したことで、この問題が再び浮上した: https://github.com/jshttp/cookie/pull/167
      そのPR以降、検証は記事で言及されているブラウザコードに近い形で、少し緩められた。
      もともとの変更は、私たちのコードで、エンコードせずに文字列を連結してCookieヘッダーを作っていたバグを見つけたことから始まった。時々値に空白が入り、リクエストが壊れていた。これを避けるために開発者にjshttp/cookieのserialize()使用を提案しようとしたが、その関数の検証では私たちが見たバグを捕まえるには不十分だと分かった。
      修正案を提案すると、別の人が検証が緩すぎてCookieの名前フィールドにJSを差し込み、別の場所でそれがのように解釈されるようにできることを発見した。かなり珍しいコードインジェクション経路になった。
    • その通りで、本当に危険要素が多い。https://www.usenix.org/conference/usenixsecurity15/technical-sessions/presentation/zhengで、この問題と関連する厄介事が詳しく扱われている。
  • 記事ではRustのアプローチに触れているが、他の言語と違ってRust標準ライブラリにはCookie処理機能は入っていない。実際にはサードパーティの**cookieクレート**の挙動を見ていることになり、Rubyのようにパーセントエンコードするオプションも含まれている: https://docs.rs/cookie/0.18.1/cookie/

    • 良い名前を早く取って、事実上の標準になるというやり方だ。
  • HTTPプロトコルの中には、実質的に互いに異なるプロトコルが1万個ほど埋め込まれているように思う。ブラウザとWebサーバーがあらゆる機能を継ぎ足し、それぞれに仕様と事実上の仕様があり、そのすべてがほぼ1つの汎用的なHTTPという傘の下で運ばれている。
    クライアントがこの1万個の非仕様のうちどのバージョンと互換性があるのかを指定することもできず、サーバーも同じだ。仕様をアップグレードできない理由は、他のクライアントが理解できず、後方互換性もないからだ。
    その結果、誰も合意できず、直すこともできないランダムな混沌が残った。計画的な廃止もないので、過去の悪い決定を引きずり続けなければならない。

    • 理解できないプロトコルを遮断してしまうひどいミドルウェア機器も原因だ。「デフォルトで失敗させておく方が安全だ」という考え方なので、今後永遠に、すべての新しいアプリケーショントラフィックは実際のインターネットで動作するためにHTTPの上にトンネリングしなければならない。
    • 正直なところ、今ではこういう世界と折り合いをつけていて、計画的な廃止がある世界より気に入っていないわけでもない。
    • 独占企業がきれいな仕様を決め、好き勝手に強制廃止できないようにするには、その代償として無政府状態を受け入れなければならない。
  • 約10年前のプロジェクトでCookieベースのセッションを実装したが、認証がSafariでは動いてChromeでは動かない理由をデバッグするのに本当に苦労した。正確にどちらだったかは覚えていないが、片方のブラウザは形式が合っていないとCookieをまったく設定しなかった。
    特別変なことをしたわけでもなく、記憶では-_の違いだった気がする。

    • SafariとChromeの間に大文字小文字の扱いの違いがあったように思う。おそらく**Set-Cookieヘッダー**だったかもしれない。
      以前この問題のせいで、CookieキーにcamelCaseを使えなかったことがある。
      検索しても正確な issue はうまく見つけられない。
  • Cookieが導入された直後から、合理的な使い方は不透明トークンだけを入れて、サーバーが次回同じクライアントだと分かるようにし、残りはすべてサーバー側に保存することだと考えられていたように思う。
    クライアントが原理的にサーバーが絶対に送らない値を処理できることが、なぜ問題なのか分からない。単にそういう値を送らなければよく、「それを送ったら何が起きるのか?」のような謎を心配する必要もない。

    • Cookieは古い技術だ。Webがまだ若かった90年代に最初に導入されたものの1つで、悪いアイデアが何度か繰り返されてきた。
      それでも不透明トークンを保存する唯一の場所なので、認証には使う必要がある。
  • Cookieヘッダーのパースはめちゃくちゃです。「標準」は実際の現場に存在する挙動を反映できておらず、バックエンドサーバー、ライブラリ、フレームワークごとに受け入れる形式が違い、ブラウザはまた別のことをします
    フロントエンドとバックエンドを完全に制御しているなら大きな問題ではありませんが、異なるもの同士を連携させる必要が出た瞬間に、あっという間にばかげた状況になります

  • Cookieは大きく複雑な混沌に見える一方で、後方互換性のためにほとんど変更も不可能です。こういうときは、完全に別の新しい仕組みを作るのが正しいのではないかと思います
    たとえば NewCookie のような仕組みを新たに仕様化し、最初から一貫して動作するように設計し直せます。現代的なセキュリティ対策を組み込み、より厳密な仕様ときちんとしたUnicodeサポートも入れられます

    • NewCookieに触れているのは面白いですが、実際にはすでに廃止された Set-Cookie2 ヘッダーがあります: https://stackoverflow.com/q/9462180/3474615
    • NewCookieは、おおよそブラウザの Local Storage に相当します
      少なくとも一部のユースケースではそうで、もちろんヘッダーと直接統合されているわけではありません
    • 核心的な問題は、Cookieがトラッキングと深く結びつきすぎている点のように思います。今より良いCookieを作ろうとすると、そのような概念自体が存在することを望まないプライバシー擁護者たちに阻まれる可能性が高いでしょう
      Cookieがすでに存在しているため、私たちはCookieに縛られています
    • クライアント側の状態を保存するうえで最も安全な場所は DOMとURL です。すべてのユースケースをカバーできるわけではありませんが、メールの事前承認リンクをクリックするような領域はカバーできます
      iOS Safariが、顧客が管理するドメインのCookieを勝手に食い潰す問題を追いかけるのに、丸々1か月を費やしました。Google、Twitter、Facebookのようなドメインでセッション状態がこのように消えるのを見たことはありません
    • 名前はNewCookieより良いものにすべきです。SuperCookie、UltraCookie、BetterCookieのような案もあり得ます
      もう少し真面目に言うと、cookieという単語を避けて、まったく別の名前を付けるのがよいでしょう。cookieという言葉には、あまりに多くの重荷が付いています
  • 筆者がJSON.stringifyの結果をCookieに入れるところから始めていたので、文字列化されるJSONの中に誰かがセミコロンを入れたせいではなかったのが、むしろ意外でした
    Cookie周りの厄介事の大半は、任意のユーザー入力をCookieに入れようとするときに起きるように思います。そうすべきではありません。認証トークンで使うような固定長の英数字ASCII文字列だけを使っていれば大丈夫です

  • かなり地雷原だという点には同意します
    開発者としての回避策は、値を URLセーフBase64 でエンコードすることです。そうすれば生のバイト値が得られ、内部表現は好きなように使えます。ただし記事でも述べているように、100%制御できるわけではありません。ユーザーエージェントなので、そうあるべきでもあります
    より多くのユーザーエージェントが、「ワイヤ上のバイト列と祈り」よりも標準準拠を選んでくれるといいのですが。スクリーンショットの400レスポンスは仕様に沿った応答です。ヘッダーが最初からUTF-8だったか、最初はASCIIで後からUTF-8を許可するほうがよかったように思います。ただし前者は因果関係上難しく、後者ももともと違法だった値を合法にするため、なお問題を引き起こし得ます

    • URLセーフBase64 と言うときは、具体的に何を指すのか必ず明示する必要があります。base64urlエンコーディングは、base64にURLエンコードを加えたものと約3%のケースで互換性がなく、開発中は見落としやすいものの、本番では必ず爆発します
    • Cookie値には =/+ 文字を入れられるので、標準の Base64エンコーディング も使えます :)
  • 記事はポステルの法則をあざ笑っていますが、Cookieを設定する側が送信時に保守的であったなら、そもそもこのような記事は必要なかったはずです

    • あざ笑われて当然です。ポステルの法則はひどいアイデアで、あちこちに地雷原を作りました
      ときにその地雷は単なるバグではなく、大きなセキュリティホールにもなります
      クライアントが仕様に合わないデータを送るなら、それはバグであり修正すべきです。サーバーが意図を推測して受け入れることが、決して当たり前になってはいけません
    • ポステルの法則の問題は、まさに送信者が決して保守的ではないところにあります。大半の受信者が受け入れる細かな挙動は、結局送信者に使われるようになります