TT Lab
はじめる
学ぶ 学習パス コース

セッションとトークン — ブラウザからメッシュまで

ブラウザは規則に合わないクッキーを黙って捨てる

TT Labで続きを見る

一言でいうと

Cookieはサーバーがブラウザーに預けておく小さな値で、6つの属性がどこへ・いつまで・誰に見えるかを決めます。セッションCookieはその値1つがそのままログインなので、属性が1つ欠けるだけでルールが崩れます。しかもブラウザーはルールに合わないCookieをエラーを出さずに捨てます。だからCookieは「送った」ではなく「受け入れられた」ところまで確認する必要があります。

なぜ必要なのか

HTTPはリクエストごとに記憶を持ちません。ログインした人を次のリクエストで見分けるには、何かを毎回送り直させる必要があり、その何かがCookieです。便利な理由はブラウザーがCookieを自動で付けてくれることですが、危険な理由も同じです。どのリクエストに付けるか、スクリプトに見せるか、平文のHTTPでも送るかをサーバーがはっきり伝えないと、ブラウザーはいちばんゆるい側で動きます。

元の規格であるRFC 6265は、このゆるさを隠していません。Cookieはポートで分離されないため、同じホストの別のポートで動くサービスも同じCookieを読めます。foo.example.comがDomain=example.comでCookieを仕込むとbar.example.comのCookieを上書きでき、受け取るサーバーはそれが誰の仕込んだものか区別できません。Pathでパスを分けても、セキュリティ境界としては信頼できないと書かれています。属性と名前の接頭辞は、この穴を1つずつ狭めるために後から付け足された仕組みです。

どう動くのか

属性はSet-Cookieの1行の後ろにセミコロンで付けます。MDN Set-Cookieを基準に整理すると次のとおりです。

Secure           HTTPS 요청에만 붙인다
HttpOnly         document.cookie 로 읽을 수 없다. 요청에는 그대로 붙는다
SameSite         다른 사이트에서 시작된 요청에 붙일지 (Strict / Lax / None)
Path             이 경로 아래 요청에만 붙인다. 보안 경계는 아니다
Domain           생략하면 보낸 호스트에만(호스트 전용), 적으면 그 도메인과 하위 전부
Max-Age/Expires  없으면 세션 쿠키, 있으면 영속 쿠키. 둘 다 있으면 Max-Age 가 이긴다

直感と逆なのがDomainです。Domain=app.example.comと書くと範囲が狭くなりそうですが、実際にはx.app.example.comのようなサブホストにもCookieが送られます。最も狭い範囲は省略です。OWASPセッション管理チートシートもDomainを完全に外すよう勧めています。同じ文書はセッションCookieを永続にしないよう求めており、ブラウザーを閉じたら消えるようにしておくほうがよいとしています。永続Cookieには上限もあります。RFC 6265bis草案は、有効期限を400日より先に設定しないよう書いています。

HttpOnlyが防ぐのはただ1つ、スクリプトが値を読むことです。MDNは、HttpOnlyのCookieもfetch()のリクエストには付くとはっきり書いています。ページにXSSがあると、攻撃スクリプトはCookieを盗めないだけで、そのブラウザーの中でユーザーのようにリクエストを送ることはそのままできます。HttpOnlyはXSS対策ではなく、被害を減らす仕組みです。

名前の接頭辞は、Cookieストアの弱点を補います。ストアは名前・ドメイン・パスでCookieを区別するだけで、誰が設定したかは記録しません。接頭辞は条件を名前そのものに刻み、その名前のCookieがあるなら条件を通過して保存されたものだとサーバーが信じられるようにします。RFC 6265bis草案(バージョン22)の保存モデルに基づく説明です。

__Secure-   Secure 가 있어야 한다
__Host-     Secure 가 있고, Domain 이 없고(호스트 전용), Path 속성이 / 여야 한다
            브라우저는 접두를 대소문자 구분 없이 대조한다 (__host- 도 같은 규칙)

同じ草案は、安全な接続ではない応答が送ってきたSecure付きCookieを無視するよう求めています。MDNはlocalhostを例外として挙げており、このラボのPodのcurl 8.5はhttp://127.0.0.1で受け取ったSecure付きCookieも保存して送り返しました(実測)。そのため、このラボは平文でも動きます。

セッションIDは、OWASPの基準では暗号論的乱数生成器が作った64ビット以上のエントロピーを持つ必要があります。Pythonならsecrets.token_urlsafe(32)が256ビットです。ログアウトでは次の2つをどちらも実施する必要があります。サーバー側のセッションを無効化し、CookieはMax-Age=0で消します。このとき削除用Cookieも接頭辞のルールを通過する必要があります。curl 8.5で試し直すと、Secureが抜けた__Host-sid=; Max-Age=0は無視され、古いCookieがストアにそのまま残りました。

現場での姿

ログアウトボタンがCookieしか消さないアプリはよくあります。画面ではログアウトしたように見えても、プロキシのログや誰かが共有したHARファイルに残った値でリクエストすると、まだログイン状態です。サーバーが記憶を消していないので、その値はまだ鍵のままです。

もう1つは「ステージングでだけログインできない」というケースです。ステージングが平文のHTTPなのにCookieにSecureが付いている場合や、__Host-のCookieに誰かがDomainを付け足した場合です。サーバーのログではSet-Cookieが正常に出ていてブラウザーが黙って捨てるので、ネットワークタブでは応答ではなく保存されたCookieの一覧を見ないと原因が見えません。

次のラボですること

127.0.0.1:8301にログインサーバーを立て、__Host-sidセッションCookieを発行します。属性をレスポンスヘッダーで確認し、curlのCookieストアに#HttpOnly_の印付きで残るかを見ます。ログアウトでは、サーバーのセッションを消さないというよくある失敗を反例にして、コピーしておいた古いCookieが401になることを自分で再現します。最後に、複数のSet-Cookie行を接頭辞のルールで判定します。