アカウントを持っていない顧客にプロジェクトを見せたい、という要望があります。当たり前の解は短いコードです。六桁、五分で失効、ゲストが入力すれば入れる。共有メモとファイルのツールでそう実装して、初日から動きました。問題は動かなかったことではありません。三か月後に二度も作り直す羽目になったことです。
三回の反復。そして三回目こそ、最初にそうすべきだったものでした。
短いコードにできること、できないこと
最初のテーブルは文字通りこれでした。
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
期限が二種類あります。これは最初から正解でした。code_expires_at は誰も使わなかった場合にコードが死ぬまでの時間(五分)で、session_duration_minutes は入ったあとの訪問が続く時間です。別物であり、混ぜるのは典型的な間違いです。WhatsApp 経由で八時間生きているコードは穴ですし、五分で切れるセッションは使い物になりません。
これで権限(read か read_write)も、どのプロジェクトにアクセスさせるかを決める中間テーブルも、失効もそろいます。完成しているように見えます。していません。コードは誰も識別しないからです。識別しているのは招待であって、ゲストではありません。
一回目の試み: 行の id を渡す
最初の修正は、リクエストごとに六桁のコードを送るのをやめ、行の id を送るようにしたことです。クライアントが codeId を保持し、バックエンドがそれを解決し、リクエストは Authorization: Bearer <codeId> で飛びます。
これは実在の問題をひとつ直します。六桁のコードが行き来しなくなる。アクセスログにも URL にも載りません。しかし、もっと悪いものを持ち込みます。codeId は自動採番の整数です。推測できます。列挙できます。有効な id をひとつ持っていれば隣の id を試せますし、そのセッションが生きていれば入れます。短い秘密を、まったく秘密でない識別子と交換したわけです。
しかも元の問題は解決していません。二人が同じ招待を使えば、codeId は共通です。片方だけを追い出すことはできません。ファイルを消したのがどちらかも分かりません。
セッショントークンが解決すること
三つ目の版は、三か月くっついていた二つのものを分けます。六桁のコードは引き換え券です。接続のエンドポイントで一度だけ使われ、それきりです。そこから出てくるのがセッショントークンです。
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
レスポンスが返すのは codeId ではなく sessionToken になりました。以降はフロントエンド全体がそれを引き回します。guestCodeId があった場所は guestSessionToken になり、ゲストかどうかを判定していた !!guestCodeId は !!guestSessionToken になりました。
実際に変わるのはここです。
- 取り消し。 ある行の
sessionTokenを null にすれば、そのセッションだけが切れます。コードのままでは、狙いを定める相手がいませんでした。 - 監査。 トークンは行にあるので、各操作を特定の引き換えと特定の時刻に結び付けられます。
- 推測不能。 もう連番の整数ではありません。ランダムな四バイトの十六進を列挙することはできません。
- 失効の置き場がひとつ。
sessionExpiresAtはトークンを発行するのと同じ update で書かれます。ずれうる時計が二つある状態になりません。
監査が見つけて、私が見つけられなかったもの
数か月後、コードを監査にかけました。いろいろ出ました。認証ガードがまったくない PDF 書き出しのルートが最悪でしたが、いちばん考えさせられたのは自分で TODO に書いた項目です。六桁のコードが使い切られても、返ってくるのは六桁プラス四バイトのトークンであり、認証するのはそちらだという点です。
そこに欠陥があります。短いコードは五分の寿命と一度しか使えないという事実で守られています。セッショントークンにはそのどちらもありません。何時間も生きて、リクエストごとに使われます。コードからトークンに変えると、ひとつのベクトルが消えて別のベクトルが開きます。そしてそれは、あとから冷静に見直さないと気づけません。四バイトは軽い列挙には十分ですが、八時間とループを持っている相手には足りません。
もうひとつ出たのは教科書どおりの話で、接続のエンドポイントが何もトランザクションで包んでいなかったことです。コードが未使用だと読むところから使用済みに印を付けるところまでの間に窓があります。同じコードで同時に来た二つのリクエストは、両方とも通っていました。
IP によるブロックは見た目より脆い
失敗回数のカウンタはリクエストの送信元 IP を知る必要があり、そこでほぼ全員が同じ間違いをします。別のプロジェクトでは IP による恒久ブロックを入れていて、x-forwarded-for の先頭の値を取っていました。このヘッダは client, proxy1, proxy2 というリストで、各プロキシは右側に追加しますが、すでに書かれていたものを誰も掃除しません。先頭を取るのは、クライアントが好きに捏造できるものを取ることです。
信頼できるプロキシが前に何段あるかに応じて、末尾から数えるように変えました。
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
プロキシが一段前にあるなら、最後の値はそのプロキシが書いたものでクライアントは触れません。先頭を取れば、誰でもゴミを送ってブロックを回避できますし、もっと悪ければ他人をブロックできます。
難しかったのは六桁のコードではありません。難しかったのは、招待とセッションは別々のオブジェクトであり、混ぜると取り消せず、監査できず、連番の識別子が資格情報の役をする状態になると認めることでした。
