セキュリティ●●●○○

SameSite属性

SameSite Attribute ・ せいむさいとぞくせい

別サイト発のリクエストに Cookie を付けるかをブラウザに指示する属性。CSRF 対策の土台。

✦ 30秒で試す — 航海同じCookieが、リンクで来れば付き、フォームで来れば付かない掲示板のフォームから銀行へ送った要求に、Cookieは?予想してみる

概要

SameSite は Cookie に付ける属性のひとつで、「別のサイトのページから出たリクエストに、この Cookie を付けて送るか」をブラウザに指示します。値は Strict・Lax・None の3つで、Strict に近づくほど、別サイト発(クロスサイト)のリクエストに Cookie が付かなくなります。サーバは Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly のように、Cookie を発行するときに書きます。

Cookie のいちばんの特徴は、宛先が合えばブラウザが自動で添えることです。この自動添付は、リクエストのきっかけがそのサイト自身のページなのか、罠ページのフォームや画像タグなのかを問いませんでした。そのため、罠ページに置いたフォームが銀行へ送信されると、ログイン中のセッション Cookie が付いて「本人の操作」として通ってしまいます。これが CSRF の前提です。SameSite は、この前提をブラウザの側で崩すための属性です。

なぜ生まれたか

CSRF への古典的な対策は、アプリが推測できないトークンを発行してフォームに埋め込み、送信時に照合することでした。確実な方法ですが、すべてのアプリがすべてのフォームで正しく実装しなければならず、実装漏れや照合ミスが後を絶ちませんでした。問題の根は「ブラウザが、どこ発のリクエストにも Cookie を付ける」という仕様側の挙動です。それなら Cookie を発行する側が「別サイト発には付けないで」と宣言できればよい、というのが SameSite の発想です。Google の Mike West らが仕様案をまとめ、2016年の Chrome 51 が最初に実装しました。Firefox は2018年の Firefox 60、Safari も同年に対応し、Cookie 仕様の改訂版(RFC 6265bis)に取り込まれています。

とはいえ、属性は書かれなければ効きません。そこで Chrome は2020年、「SameSite を書いていない Cookie を Lax として扱う」既定値の変更に踏み切りました(Chrome 80 から段階的に実施)。それまで属性なしの Cookie は無条件に送られていたので、Web 全体の CSRF 耐性が一気に上がりました。一方で、別サイトからの POST で戻ってくる決済や SSO のような正規の連携が各所で壊れ、「ブラウザの既定値の変更が世界中のサイトの動作を変えうる」ことを示した出来事にもなりました。なお、この既定値の変更を行ったのは Chrome・Edge などの Chromium 系だけで、Firefox と Safari は属性のない Cookie を Lax として扱っていません。

詳細

「サイト」とは何か

SameSite の「サイト」は、CORS の「オリジン」より粗い単位です。オリジンがスキーム・ホスト・ポートの組であるのに対し、サイトは「スキーム+登録可能ドメイン」で決まります。登録可能ドメインとは、DNS の名前のうち、ドメインとして取得できる単位(example.com や example.co.jp)のことで、どこまでが公開された接尾辞かは Public Suffix List という一覧で決まります。bank.example.com と blog.example.com は別オリジンですが、同じサイトです。逆に alice.github.io と bob.github.io は、github.io が公開接尾辞として登録されているので別サイトです。スキームも判定に入るので(schemeful same-site。Chrome 89 から)、http://example.com から https://example.com へのリクエストはクロスサイトになります。

サイト: https + example.combank.example.comオリジン1Cookie の持ち主blog.example.comオリジン2ここに穴があると踏み台別オリジンでも同一サイト → SameSite では区別されないevil.example登録可能ドメインが違う→ クロスサイトhttp://example.comスキームが違う→ クロスサイトオリジン = スキーム + ホスト + ポート(CORS が見る単位)サイト = スキーム + 登録可能ドメイン(SameSite が見る単位)
オリジンとサイトの範囲の違い。SameSite が見るのはサイト(スキーム+登録可能ドメイン)なので、同じ登録ドメインのサブドメインどうしは「同一サイト」になる

3つの値とリクエストの種類

ブラウザは、リクエストを送る直前に「このリクエストを出したページのサイト」と「宛先のサイト」を比べます。同じなら値に関係なく Cookie を付けます。違う(クロスサイト)なら、値とリクエストの種類で決めます。

StrictLaxNone同一サイトからのリクエストサブドメイン間を含む送る送る送るクロスサイトのリンク遷移アドレスバーが変わる GET送らない送る送るクロスサイトのフォーム POSTCSRF の典型・SSO の form_post送らない送らない送るクロスサイトの埋め込み・通信img・iframe・fetch など送らない送らない送るNone は Secure 属性(HTTPS でだけ送る)との組み合わせが必須。属性なしの Cookie を Lax として扱うのは Chromium 系だけ(Firefox・Safari は従来どおり送る)。
クロスサイトのリクエストに Cookie が付くか。Lax の境界線は「トップレベルの遷移で、かつ GET」

Strict は、クロスサイト発のリクエストには一切 Cookie を付けません。いちばん堅い値ですが、メールやほかのサイトのリンクからログイン中のサイトへ移ったときも最初の1ページは未ログインに見えるので、体験を損ないやすい値です。Lax はそこに例外を1つだけ設けます。「トップレベルの遷移(アドレスバーの URL が変わる移動)で、かつ GET などの安全なメソッド」なら Cookie を付けます。リンクから来た人はログインしたまま迎えつつ、状態を変える POST や、ページの中に隠れた img・iframe・fetch には付けない、という落としどころです。None は従来どおり無条件に付ける値で、別サイトに埋め込まれるウィジェットや SSO のように、クロスサイトで Cookie が必要な正規の用途のためにあります。None には Secure 属性(HTTPS のときだけ送る)が必須で、平文の通信でクロスサイトに送られる Cookie はブラウザが受け付けません。

Chrome が既定値を Lax に変えたとき、ログイン直後の POST が壊れるのを避けるため「Lax+POST」という経過措置も入りました。属性のない Cookie に限り、発行から2分以内であれば、トップレベルのクロスサイト POST にも付ける、というものです。「属性なしの Cookie は 2分間だけ None 相当」と説明されることがありますが、対象はトップレベルの POST だけで、img や fetch には付きません。

下の部品では、罠ページや同じ登録ドメインの別サイト、IdP のページにあるリンク・フォーム・img を直接つかんで bank.example.com へ投げ込みます。ブラウザの Cookie 入れがサイトを比べて Cookie を付けるかどうかを決める様子と、表が埋まっていく様子を確かめてください。

⚡ 体験: Cookie を付けるかはブラウザがサイトで決める
Cookie の属性
あなたのブラウザ(bank.example.com にログイン中)https://evil.example罠ページ・別サイトhttps://blog.example.com同じサイトhttps://idp.exampleIdP・別サイト攻撃者が書き込める掲示板ログイン後、結果を POST で返すCookie 入れsession=abc…SameSite=Laxbank.example.comalice のセッションを待つサイト: example.comリンク(GET 遷移)フォーム POST<img>フォーム POSTform_post で戻る
○ 罠の POST を止める○ Lax でも付く道を見つける○ サブドメインを踏み台にする○ SSO の戻りが壊れる
StrictLaxNone
罠ページのリンクトップレベル GET???
罠ページのフォームトップレベル POST???
罠ページの img埋め込み(サブリソース)???
blog.example.com のフォーム同じ登録ドメイン???
IdP の form_postSSO の戻り(正規)???

罠ページ(evil.example)のフォーム POSTをつかんで、右の bank.example.com へ投げ込んでください。Cookie は SameSite=Lax です。タップするとおまかせで送ります。

CSRF 対策としての位置づけ: 単独では守りきれない

Lax を明示すれば多くの CSRF は止まりますが、SameSite だけに頼るのは危険です。第一に、Lax はトップレベルの GET 遷移には Cookie を付けるので、状態の変更を GET で受け付けているエンドポイントは守れません。第二に、「サイト」はサブドメインを区別しないので、blog.example.com のような同じサイトのどこかに XSS や、利用者が自由に HTML を置ける場所があると、そこから出たリクエストは「同一サイト」として Cookie 付きで通ります。第三に、Firefox と Safari は属性のない Cookie を Lax として扱わないので、既定値に頼った設計はブラウザによって守りが変わります。結論として、SameSite は明示し(Lax か Strict)、重要な操作には CSRFトークンや Origin ヘッダの検証を重ねる、という多層防御が標準です。

実務の落とし穴: 正規のクロスサイト連携が壊れる

SameSite でつまずくのは、攻撃よりも正規の連携です。典型が OAuth や SSO の戻りです。OIDC の response_mode=form_post では、IdP のページが自動送信フォームで結果を redirect_uri へ POST します。これはクロスサイトのトップレベル POST なので、アプリ側のセッション Cookie(ログイン開始時に state などを覚えておく Cookie)が Lax や Strict だと付かず、「ログインしたはずなのに未ログイン扱い」「state が一致しない」という不具合になります。決済代行からの POST での戻り、別サイトに iframe で埋め込まれるアプリも同様です。対処は、その連携に必要な Cookie だけを SameSite=None; Secure にする、戻りを GET のリダイレクトにする、状態を Cookie 以外(URL の state パラメータとサーバ側の保存)で受け渡す、などです。

最後に守備範囲の整理です。SameSite は「Cookie を付けるか」の制御で、リクエスト自体は届きます。レスポンスの読み取りを制御するのは CORS、スクリプトの実行を制御するのは CSP です。同じ「ブラウザ側の防御」でも、守っている場所が違います。

試問

ブラウザの中に、罠ページ(evil.example)のリンク・フォーム・隠れた img と、同じドメインの掲示板や、ログインを代行するサービス(IdP)のページがあります。それぞれをつかんで(タップでも可)、ログイン中の bank.example.com へ投げ込むと、ブラウザがセッション Cookie を付けるかどうかと銀行の反応が見えます。Cookie の SameSite 属性(Strict・Lax・None)は切り替えられ、いまは None です。

✦ 命題挑戦中

罠ページからの送金フォームと隠れた画像では銀行のログインを使わせず、ほかのサイトのリンクから来た利用者はログインのまま迎える属性を選び、同じ属性のまま3つを投げ込んで確かめよ

  • 罠ページのフォーム POST に Cookie が付かない(送金が止まる)
  • 罠ページのimg にも Cookie が付かない
  • 別サイトのリンクから来た人は、ログインのまま迎えられる
⚡ 体験: Cookie を付けるかはブラウザがサイトで決める
Cookie の属性
あなたのブラウザ(bank.example.com にログイン中)https://evil.example罠ページ・別サイトhttps://blog.example.com同じサイトhttps://idp.exampleIdP・別サイト攻撃者が書き込める掲示板ログイン後、結果を POST で返すCookie 入れsession=abc…SameSite=None; Securebank.example.comalice のセッションを待つサイト: example.comリンク(GET 遷移)フォーム POST<img>フォーム POSTform_post で戻る
StrictLaxNone
罠ページのリンクトップレベル GET???
罠ページのフォームトップレベル POST???
罠ページの img埋め込み(サブリソース)???
blog.example.com のフォーム同じ登録ドメイン???
IdP の form_postSSO の戻り(正規)???

いまのセッション Cookie は SameSite=None; Secure です。属性を選び、罠ページのリンク・フォーム・img を右の bank.example.com へ投げ込んで(タップでも送れます)、Cookie が付くかを確かめてください。