Skip to main content

クッキー — 誰が、いつ、どこへ自動で送るか

クッキーの厄介さは、開発者が明示的に送らなくてもブラウザが勝手に付けることにあります。この自動送出があるからログイン状態が保てる一方、CSRF が成立する理由もここにあります。

そして属性の意味を取り違えやすい。Domain を指定すると範囲が絞られると思っている人が多いのですが、実際は逆に広がります。

この章で学ぶこと

  • クッキーの属性が「どこへ送るか」「いつまで持つか」「誰が読めるか」のどれを決めているか
  • クッキーのスコープがオリジンと一致しないこと。scheme と port を無視する
  • SameSite の 3 値と、その判定が site 単位である理由
  • 「ログインが切れる」を属性から切り分ける手順
前提知識

HTTP のヘッダーとステートレス性を前提にします。クッキーは Set-CookieCookie という 1 組のヘッダーで動きます。

クッキーは何のためにあるか

HTTP はステートレスです。サーバーはリクエスト間で状態を覚えないので、ログイン状態のような継続する情報は毎回のリクエストで運ぶしかありません。

運ぶ方法は 2 つあります。

方法誰が付けるか
Authorization ヘッダーアプリケーションのコードが明示的に付ける
クッキーブラウザが条件に合致すれば自動で付ける

この違いが後の話すべてに効きます。自動送出は便利ですが、開発者が意図しないリクエストにも付くということです。

サーバーが渡し、ブラウザが返す

HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

ブラウザはこれを保存し、条件に合うリクエストで返します。

GET /mypage HTTP/1.1
Host: example.com
Cookie: session_id=abc123

Set-Cookie1 ヘッダーにつき 1 個です。3 個渡すなら Set-Cookie を 3 行書きます。逆に返す Cookie ヘッダーは 1 行にまとめられ、name=value; で区切って並べます。

属性を役割で分ける

属性は 3 つの問いに答えています。

1. どこへ送るか

属性意味
Domain送る対象のホスト。指定するとサブドメインへ広がる
Path送る対象のパス。前方一致で判定する
SameSite他サイトからのリクエストに付けるか
SecureHTTPS のときだけ送る

2. いつまで持つか

属性意味
Expires期限を日時で指定する
Max-Age期限を秒数で指定する。Expires より優先される
(どちらも無し)セッションクッキー。ブラウザを閉じると消える

削除は Max-Age=0 を送ります。過去の日時を Expires に入れても同じです。

3. 誰が読めるか

属性意味
HttpOnlyJavaScript から読めなくする (document.cookie に現れない)
Partitioned埋め込み先のサイトごとに別のクッキーとして扱う

スコープはオリジンではない

ここが最も誤解されます。オリジンは scheme + host + port の 3 つ組ですが、クッキーのスコープは host + path で決まり、scheme と port を見ません

https://example.com:443 で設定したクッキーは
http://example.com:8080 のリクエストにも付く

Secure を付ければ scheme は絞れます。しかしport を絞る手段はありません (draft-ietf-httpbis-rfc6265bis-21 は port をセキュリティ境界と見なさない立場を取っています)。ローカル開発で複数のポートを使うとき、クッキーが混ざるのはこのためです。

Domain は絞らずに広げる

Set-Cookie: a=1 (Domain 指定なし)
Set-Cookie: b=2; Domain=example.com (Domain 指定あり)
クッキーexample.comapp.example.com
a (指定なし)送る送らない
b (Domain=example.com)送る送る

指定しないほうが範囲が狭くなります。Domain を書くと、そのドメインとすべてのサブドメインが対象になるからです。

範囲を広げたい理由がないなら書かないのが安全です。書くのは「app.example.comapi.example.com で同じセッションを使いたい」のような場合だけです。

SameSite — 他サイトからのリクエストに付けるか

他サイトからのリクエストに付くか
Strict付かない
Laxトップレベルの遷移かつ安全なメソッドのときだけ付く
None常に付く。Secure が必須

NoneSecure が要るのは仕様上の要求です (同 draft の Section 5.7 が same-site-flagNone かつ secure-only-flag が false ならクッキーを無視すると定めています)。

判定は site 単位で、オリジン単位ではない

「SameSite」の名前が示すとおり、判定単位は site です。site は登録可能ドメイン (eTLD+1) で決まります。

遷移元 → 遷移先同一 site か
app.example.comapi.example.com同一 (どちらも example.com)
example.comexample.co.jp
alice.github.iobob.github.io (github.io は公開サフィックス)

つまり SameSite はサブドメイン間の移動を制限しません。同一 site 内なら Strict でも送られます。

Strict は利用体験を損ないます。外部サイトのリンクから来たユーザーはクッキーが送られないので、ログイン済みなのに未ログイン扱いになります。この問題があるので、Lax を既定にして CSRF 対策を別に置くのが現実的です。SameSite 単独で CSRF を防げるかについては Web アプリの主要な攻撃と対策 が OWASP の立場を扱っています。

名前の接頭辞で制約を宣言する

属性は Cookie ヘッダーで返ってきません。返るのは name=value だけです。だからサーバーは「このクッキーが Secure 付きで設定されたか」を後から知れません。

これを名前で解決するのが接頭辞です。

接頭辞ブラウザが要求する条件
__Secure-Secure が付いていること
__Host-Secure が付き、Domain無くPath=/ であること

条件を満たさない Set-Cookie はブラウザが拒否します。名前が返ってきた時点で条件が満たされていたと分かるので、サーバー側の検証が不要になります。

認証クッキーには __Host- が最も強い制約です。Domain を禁じるので、サブドメインから上書きされる攻撃を構造的に防げます。

「このリクエストにクッキーが付くか」の判定

「ログインが切れる」を切り分ける

症状から属性を絞ります。上から順に確認します。

症状疑う属性
サブドメインへ移ると切れるDomain 未指定 (そのホストにしか送られない)
ローカルでは動くが本番で切れるSecure (本番は HTTPS、ローカルは http)
特定のページだけ切れるPath の前方一致から外れている
外部サイトのリンクから来ると切れるSameSite=Strict
ブラウザを閉じると切れるMax-Age / Expires が無い (セッションクッキー)
iframe 内で切れるSameSiteNone でない、または Partitioned

DevTools の Application タブでクッキーの属性を直接見られます。推測する前に実際の属性を見るのが速いです。

認証クッキーの既定値

判断に迷ったらここから始めます。

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
指定理由
__Host-Domain を禁じ、サブドメインからの上書きを防ぐ
Secure平文で流れないようにする
HttpOnlyXSS が起きてもスクリプトから読めない
SameSite=Lax他サイトからの POST に付かない。Strict の体験劣化を避ける
Path=/__Host- の要求。サイト全体で使う

ここから外すときは理由が要ります。サブドメイン間で共有したいなら __Host- を諦めて Domain を指定する、というように何を捨てるかを意識します。

よくある誤解

HttpOnly を付ければ XSS 対策になる」 — なりません。クッキーの窃取は防げますが、スクリプトが動く以上、そのスクリプトがブラウザに代わってリクエストを送れます。クッキーは自動で付くので、値を読めなくても操作はできます。

Domain を指定すると対象が絞られる」 — 逆です。指定するとサブドメインへ広がります。絞りたいなら指定しません。

Secure はクッキーの中身を暗号化する」 — しません。HTTPS のときだけ送るという指定で、値そのものは平文です。暗号化しているのは TLS です。

「クッキーは同一オリジンにしか送られない」 — scheme と port を無視するので、オリジンより広い範囲に送られます。

SameSite=Strict にすれば CSRF は防げる」 — 同一 site 内からの攻撃は防げません。サブドメインを乗っ取られた場合、Strict でもクッキーは送られます。

確認問題

問 1. Set-Cookie: token=x; Domain=example.com; Path=/api を返しました。次のうちクッキーが送られるのはどれですか。
A. https://example.com/api/orders
B. https://example.com/mypage
C. https://app.example.com/api/orders
D. http://example.com/api/orders

答え: A・C・D

判定理由
A送るhost も path も合致する
B送らないPath=/api の前方一致から外れる
C送るDomain 指定でサブドメインも対象
D送るSecure を付けていないので http でも送る

D が引っかかりやすい点です。DomainPath は絞っても、scheme は Secure を付けない限り絞られません。token という名前のクッキーが平文で流れる設計になっています。

問 2. SPA からの API 呼び出しでクッキーが送られません。SameSite=None; Secure を付けたのに変わりません。何を確認しますか。

答え: リクエスト側が資格情報を送る設定になっているかを確認します。

SameSite=None はブラウザ側の制限を外す指定ですが、それだけでは足りません。fetch は既定でクロスオリジンのリクエストにクッキーを付けないので、credentials: 'include' が要ります。

さらにサーバー側も条件を満たす必要があります。

必要なもの理由
Access-Control-Allow-Credentials: true資格情報付きのリクエストを許可する
Access-Control-Allow-Origin具体的なオリジン* は資格情報付きでは使えない

つまりクッキー側の設定・リクエスト側の設定・CORS の設定の 3 つが揃わないと送られません。詳しくは オリジンと CORS で扱います。

問 3. app.example.com の管理画面が乗っ取られました。Domain=example.com のセッションクッキーを使っています。何が起きますか。

答え: example.com と、すべてのサブドメインのセッションが影響を受けます。

Domain=example.com を指定したクッキーは、app.example.com からも読み書きできます。乗っ取られたサブドメインで動くスクリプトは次ができます。

  • クッキーを読む (HttpOnly なら読めませんが、リクエストには自動で付く)
  • 同じ名前のクッキーを上書きする (セッション固定攻撃)

HttpOnly は読み取りを防ぎますが、上書きは防ぎません。攻撃者が用意したセッション ID を書き込めば、被害者はそのセッションでログインすることになります。

これを構造的に防ぐのが __Host- です。Domain を禁じるので、サブドメインは親ドメインのクッキーに手を出せません。代償はサブドメイン間でセッションを共有できないことです。共有が要るなら、サブドメインの信頼境界を設計で分ける必要があります。

まとめ

  • クッキーはブラウザが自動で送る。この自動送出がログイン維持を支え、同時に CSRF を成立させる
  • スコープはオリジンではない。host + path で決まり、scheme と port を見ない
  • Domain を指定するとサブドメインへ広がる。絞りたいなら指定しない
  • SameSite の判定は site (eTLD+1) 単位。サブドメイン間の移動は制限しない
  • HttpOnly は読み取りを防ぐが上書きは防がない。上書きを防ぐのは __Host-
  • 属性は Cookie ヘッダーで返らない。だから名前の接頭辞で条件を宣言する
  • 認証クッキーの出発点は __Host- + Secure + HttpOnly + SameSite=Lax
関連リファレンス

次に読む