__Host- セッションクッキーを発行し、正しく消す
目標
127.0.0.1:8301にログインサーバーを立てて__Host-sidセッションCookieを発行・検査・取り消しし、Cookieがブラウザー(curl)のストアに実際に受け入れられ、削除されるところまで確認します。
なぜ重要なのか
Cookieの問題はサーバーのログでは見えません。サーバーはSet-Cookieを正常に出したのに、ブラウザーが接頭辞のルールやSecureの条件に引っかかって黙って捨てると、症状は「たまにログインできない」としか現れません。そこでこのラボは、レスポンスヘッダーと合わせてcurlのCookieストアを見ます。curl 8.5も__Host-・__Secure-のルールに反するCookieは保存しないので、ストアに残っているかどうかが、受け入れられたかどうかの近似になります。ログアウトも同じ目で見ます。Cookieを消すのはブラウザーへのお願いにすぎず、値を書き写した人にとって意味があるのは、サーバーの記録が消えたかどうかだけです。
ステップ
/root/st/cookie/app.pyを127.0.0.1:8301で起動してください。GET /healthzは200と{"ok":true}を返します。POST /loginにJSON{"user":"alice","password":"wonderland"}を送ると、200と{"user":"alice"}を返し、__Host-sidCookieを発行します。値はsecretsで作った128ビット以上のランダム値(base64urlで22文字以上)で、ログインのたびに変わる必要があり、ユーザー名を含めてはいけません。bob/builderでもログインできます。- セッションCookieに
Secure、HttpOnly、SameSite=Lax、Path=/を付け、DomainとMax-Age/Expiresは付けないでください(ホスト専用のセッションCookie)。aliceでログインしながらcurl -c /root/st/cookie/jar.txtでCookieストアを残してください。そのファイルの__Host-sidの行は#HttpOnly_127.0.0.1で始まり、パスが/、SecureがTRUE、有効期限が0である必要があります。 GET /meは、有効な__Host-sidがあれば200と{"user":"<사용자>"}(プレースホルダーはユーザー名です)を返し、Cookieがない場合やサーバーが発行したことのない値の場合は401を返します。- 間違ったパスワード(
alice/wrongpass)と存在しないユーザー(mallory)のPOST /loginは401を返し、Set-Cookieが1行もあってはいけません。 POST /logoutは200を返し、サーバー側のセッションを消します。レスポンスには__Host-sidを消すSet-Cookie(Max-Age=0)が必要で、curlのストアがその削除を実際に受け入れてCookieが消える必要があります。ログアウト前にコピーしておいたCookieの値でGET /meを呼ぶと401になる必要があります。ステップ3のjar.txtは上書きせず、別のストアファイルで試してください。https://app.example.com/loginのレスポンスとして次のSet-Cookie値が届いたとき、ブラウザーがCookieを保存するかどうかを判定し、/root/st/cookie/prefix.txtに번호=acceptまたは번호=rejectの形で(プレースホルダーは番号です)1行ずつ書いてください。- 1
__Host-sid=a1; Secure; HttpOnly; Path=/; SameSite=Lax - 2
__Host-sid=a2; HttpOnly; Path=/; SameSite=Lax - 3
__Host-sid=a3; Secure; Path=/; Domain=app.example.com - 4
__Host-sid=a4; Secure; Path=/account - 5
__Secure-theme=dark; Secure; Path=/account; Domain=example.com - 6
__Secure-theme=dark; Path=/ - 7
__host-sid=a7; Path=/ - 8
theme=dark; Path=/; Domain=example.com
- 1
/root/st/cookie/e2e.shで、ログイン、Cookieでの/me、Cookieなしの/me、間違ったパスワード、ログアウト、古いCookieの再利用を順に試してください。ログアウト後にストアに残った__Host-sidの行数を数え、/root/st/cookie/e2e.outにlogin=200 me=200 no_cookie=401 bad_password=401 logout=200 replay=401 jar_after_logout=0を1行で書いてください。
参考
- レスポンスヘッダーの確認は
curl -s -D - -o /dev/null ...、ストアの保存と利用はcurl -c <파일> -b <파일> ...(プレースホルダーはファイル名です)で行えます。 - curlのストアファイルはタブ区切りの7列です。
awk -F'\t'で列を取り出せます。 - このPodのcurl 8.5は、
http://127.0.0.1で受け取ったSecure付きCookieも保存して送り返します。実際のブラウザーではHTTPSが必要です(localhostは例外)。 - よくある失敗1: ログアウトでCookieだけを消し、サーバーのセッションを残してしまうケースです。書き写した値がそのまま通用し続けます。
- よくある失敗2: 削除用Cookieに
SecureやPath=/を付け忘れるケースです。__Host-のルールに引っかかって削除そのものが無視され、古いCookieが残ります。
ログインサーバーを起動する
/root/st/cookie/app.pyを127.0.0.1:8301で起動してください。GET /healthzは200と{"ok":true}を返します。
標準ライブラリのhttp.serverで十分です。サーバーはバックグラウンドで起動し、/healthzが応答するまで待ってから次へ進んでください。
ログインでセッションCookieを発行する
POST /loginにJSON{"user":"alice","password":"wonderland"}を送ると、200と{"user":"alice"}を返し、__Host-sidCookieを発行します。値はsecretsで作った128ビット以上のランダム値(base64urlで22文字以上)で、ログインのたびに変わる必要があり、ユーザー名を含めてはいけません。bob/builderでもログインできます。
セッション値は意味を持たない鍵です。randomモジュールは予測できるので、暗号論的乱数を使うsecretsモジュールで、URLに安全な文字列を作る関数を探してみてください。誰のセッションかはサーバーがdictに記録します。
Cookieの属性とストアを確認する
セッションCookieにSecure、HttpOnly、SameSite=Lax、Path=/を付け、DomainとMax-Age/Expiresは付けないでください(ホスト専用のセッションCookie)。aliceでログインしながらcurl -c /root/st/cookie/jar.txtでCookieストアを残してください。そのファイルの__Host-sidの行は#HttpOnly_127.0.0.1で始まり、パスが/、SecureがTRUE、有効期限が0である必要があります。
Domainは書くほど範囲が広がります。省略して初めて、送ったホストだけに送られます。有効期限を書かなければ、ブラウザーを閉じると消えるセッションCookieになります。curlのストアファイルはタブ区切りの7列で、HttpOnlyのCookieは最初の列の前に印が付きます。
Cookieでユーザーを見分ける
GET /meは、有効な__Host-sidがあれば200と{"user":"<사용자>"}(プレースホルダーはユーザー名です)を返し、Cookieがない場合やサーバーが発行したことのない値の場合は401を返します。
Cookieが付いて届いたという事実だけでは何も証明されません。その値でサーバーのセッション記録を探し、ユーザーを取り出す必要があります。
失敗したログインにはCookieを渡さない
間違ったパスワード(alice/wrongpass)と存在しないユーザー(mallory)のPOST /loginは401を返し、Set-Cookieが1行もあってはいけません。
Cookieを作るコードがパスワード検査より前にあると、失敗してもCookieが出てしまいます。パスワードの比較には、時間差が漏れない関数を使うほうがよいです。
ログアウトを本物にする
POST /logoutは200を返し、サーバー側のセッションを消します。レスポンスには__Host-sidを消すSet-Cookie(Max-Age=0)が必要で、curlのストアがその削除を実際に受け入れてCookieが消える必要があります。ログアウト前にコピーしておいたCookieの値でGET /meを呼ぶと401になる必要があります。ステップ3のjar.txtは上書きせず、別のストアファイルで試してください。
Cookieの削除はお願いにすぎません。値を書き写した人にとっては、サーバーの記録が消えたかどうかだけが意味を持ちます。そして削除用Cookieもまた1つのCookieなので、__Host-接頭辞の条件をもう一度通過して初めてブラウザーが受け入れます。
接頭辞のルールを判定する
https://app.example.com/loginのレスポンスとして次のSet-Cookie値が届いたとき、ブラウザーがCookieを保存するかどうかを判定し、/root/st/cookie/prefix.txtに번호=acceptまたは번호=rejectの形で(プレースホルダーは番号です)1行ずつ書いてください。1 __Host-sid=a1; Secure; HttpOnly; Path=/; SameSite=Lax・2 __Host-sid=a2; HttpOnly; Path=/; SameSite=Lax・3 __Host-sid=a3; Secure; Path=/; Domain=app.example.com・4 __Host-sid=a4; Secure; Path=/account・5 __Secure-theme=dark; Secure; Path=/account; Domain=example.com・6 __Secure-theme=dark; Path=/・7 __host-sid=a7; Path=/・8 theme=dark; Path=/; Domain=example.com
接頭辞ごとに要求される条件の一覧を先に書き出し、1行ずつ照合してください。__Host-は3つ、__Secure-は1つです。ブラウザーが接頭辞を大文字と小文字を区別して照合するかどうかも、規格で確認してみてください。#で始まる行はコメントとして残せます。
全体の流れを一度に証明する
/root/st/cookie/e2e.shで、ログイン、Cookieでの/me、Cookieなしの/me、間違ったパスワード、ログアウト、古いCookieの再利用を順に試してください。ログアウト後にストアに残った__Host-sidの行数を数え、/root/st/cookie/e2e.outにlogin=200 me=200 no_cookie=401 bad_password=401 logout=200 replay=401 jar_after_logout=0を1行で書いてください。
前のステップでの確認を、1つのCookieストアでつなげて実行すれば足ります。再利用の試験に使う古い値は、ログアウトする前にストアから取り出しておく必要があります。