Claude Codeでパスワードリセット実装をレビューする — トークン・期限・ユーザー列挙
OWASPのForgot Password Cheat Sheetを観点表にし、plan modeのClaude Codeに自社のパスワードリセット実装を突き合わせさせる手順です。一回性・期限・ユーザー列挙はテストで確かめます。
パスワードリセットは、動作確認では「メールが届いてパスワードが変わる」ところまでしか見えません。壊れ方が見えるのは、存在しないメールアドレスを入れたとき、同じリンクを二度開いたとき、期限を過ぎてから開いたときです。
この記事では、OWASPのForgot Password Cheat Sheetを観点表にして、Claude Codeに自社の実装を突き合わせさせます。指摘は読んで終わりにせず、ユーザー列挙・一回性・期限のそれぞれをテストに落として、実際に抜けているかを確かめます。
OWASPの項目をレビュー観点の表にする
レビュー役に「パスワードリセットを見て」と頼むと、一般論の寄せ集めが返りがちです。コードのどこを見て何を判定するのかを、先に観点として渡します。観点はOWASPのチートシートから取ります。
チートシートは、この機能を「ユーザー列挙攻撃に代表される脆弱性の温床」と位置づけたうえで、守るべき点を短く列挙しています。そのうち、コードで判定できるものを表にします。
| 観点 | OWASPの記述 | コードで見る場所 | 判定の仕方 |
|---|---|---|---|
| 応答の一貫性 | OWASPの記述存在するアカウントにもしないアカウントにも同じメッセージを返す | コードで見る場所リセット要求ハンドラの分岐 | 判定の仕方2通りの入力で応答を比較するテスト |
| 応答時間 | OWASPの記述応答にかかる時間を揃える。非同期呼び出しにするか、早期リターンを避けて同じ処理を通す | コードで見る場所存在確認の後のメール送信 | 判定の仕方処理経路が同じかをコードとテストで見る |
| トークンの生成 | OWASPの記述暗号論的に安全な乱数、十分な長さ | コードで見る場所トークン生成の1行 | 判定の仕方Math.random など非暗号用の乱数がないか |
| 一回性と期限 | OWASPの記述使用後に無効化、適切な期間で失効 | コードで見る場所トークンの消費クエリ | 判定の仕方同じトークンの再利用と期限切れのテスト |
| リンクの組み立て | OWASPの記述Hostヘッダに頼らない。HTTPSを使う | コードで見る場所メール本文のURL生成 | 判定の仕方偽のHostヘッダを付けた要求のテスト |
| 総当たり対策 | OWASPの記述URL内のトークン総当たりに対する保護、アカウント単位のレート制限 | コードで見る場所ルートに付くミドルウェア | 判定の仕方閾値超過で拒否されるか |
| ロックアウト | OWASPの記述忘失攻撃でアカウントをロックしない | コードで見る場所失敗時の処理 | 判定の仕方ロックの副作用がないか |
表の「判定の仕方」が、そのまま後半のテストになります。コードを読んで「たぶん大丈夫」と言わせるのではなく、判定できる形に直してから渡す、というのがこの手順の肝です。
plan modeで読み取りだけのレビューを頼む
レビューの段階では、コードを書き換えさせたくありません。Claude Codeにはplan modeがあり、ファイルを読んで計画を提示するだけで、承認するまで編集しません。起動時に指定できます。
claude --permission-mode plan起動後にShift+Tabを押して、ステータスバーに ⏸ plan mode on が出る状態に切り替える方法もあります。
観点表をプロンプトに貼り、対象ファイルを名指しします。出力の形も先に決めておくと、後でテストに変換しやすくなります。
src/auth/ のパスワードリセット実装(forgot と reset の2ルート)をレビューして。
編集はしないこと。次の観点表の各行について、
該当するコードの場所(ファイル:行)、OWASPの記述に反するか、
反するなら再現するテストの案を出して。
(ここに上の観点表を貼る)公式の共通ワークフローには、テストについて「どの挙動を確かめたいかを具体的に頼む」「エッジケースの洗い出しを頼む」という指針があります。観点表はその「具体的な挙動」にあたります。
返ってきた指摘は、そのまま信じず、次の節からのテストで一件ずつ確かめます。
ユーザー列挙: 存在の有無で応答が変わっていないか
最初に疑うのは、リセット要求ハンドラの分岐です。次のような実装は、実務でもよく見かける形です(以下のコードは説明用の例です)。
app.post("/forgot", async (req, res) => {
const user = await db.user.findByEmail(req.body.email);
if (!user) return res.status(404).json({ error: "No such user" });
const token = Math.random().toString(36).slice(2);
await db.resetToken.insert({ userId: user.id, token });
await mail.send(user.email, `${req.headers.origin}/reset?token=${token}`);
res.json({ ok: true });
});この数行に、観点表の4行が同時に引っかかります。
- 存在しない場合だけ404と別メッセージを返す(応答の一貫性)
- 存在する場合だけメール送信を待つので、応答時間が長くなる(応答時間)
- トークンを
Math.randomで作っている(トークンの生成) - リンクのURLを
Originヘッダから組み立てている(リンクの組み立て)
OWASPは応答時間を揃える手段として、非同期呼び出しにするか、早期リターンせず同じ処理を通すことを挙げています。要求を受けた時点では存在確認もメール送信もせず、ジョブを積むだけにすれば、どちらの入力でも同じ経路を通ります。
app.post("/forgot", forgotLimiter, async (req, res) => {
const email = normalizeEmail(req.body.email);
await queue.add("send-reset", { email });
res.status(202).json({
message: "登録があれば、リセット用のメールを送信します。",
});
});存在確認とメール送信は、ワーカー側の send-reset ジョブが担当します。ここでは「存在しなければジョブ内で何もせず終わる」だけで済みます。
応答の一致をテストにする
確かめたいのは「2つの入力で、ステータスとボディが一致すること」と「どちらもジョブが積まれること」です。時間そのものは測りません。
import request from "supertest";
import { app, queue, seedUser } from "./helpers";
test("存在する/しないメールで応答が一致する", async () => {
await seedUser("alice@example.com");
const hit = await request(app)
.post("/forgot").send({ email: "alice@example.com" });
const miss = await request(app)
.post("/forgot").send({ email: "nobody@example.com" });
expect(miss.status).toBe(hit.status);
expect(miss.body).toEqual(hit.body);
expect(queue.add).toHaveBeenCalledTimes(2);
});実時間の差をアサートするテストは、CIの負荷でぶれて不安定になります。経路が同じであることを、ジョブが両方で積まれる事実で押さえるほうが堅実です。時間差の検査は、別に手動の負荷試験として残す形が現実的です。
プロンプトでは、このテストをClaude Codeに書かせて実行させます。失敗するテストを先に見せて、その後で実装を直させる順にすると、直した結果が観点に効いているかを自分の目で確認できます。
トークンの一回性と期限
次はリセット実行側です。OWASPは、トークンを使用後に無効化することと、適切な期間で失効させることを求めています。よくある穴は、トークンを引いて検証するだけで、使用済みの印を付けない実装です。
app.post("/reset", async (req, res) => {
const row = await db.resetToken.find(req.body.token);
if (!row) return res.status(400).end();
await db.user.setPassword(row.userId, req.body.password);
res.json({ ok: true });
});ここには一回性も期限もありません。さらに「検索して、その後に更新する」の2段階なので、同時に2回送られると両方が通る隙もあります。消費を1つのSQLにまとめ、条件の判定と使用済みの記録を同時に行います。
const hash = sha256(req.body.token);
const { rows } = await db.query(
`UPDATE reset_tokens
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING user_id`,
[hash],
);
if (rows.length === 0) return res.status(400).end();トークンはハッシュで保存しています。OWASPは「安全な方法で保存する」ことを求めており、DBの内容が漏れても有効なリンクが再構成されないようにする、というのは実装上の選択です。生成側は、暗号論的に安全な乱数で十分な長さのトークンを作ります。
import { randomBytes, createHash } from "node:crypto";
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");再利用と期限切れをテストにする
test("同じトークンは2回目に拒否される", async () => {
const token = await issueResetToken("alice@example.com");
const first = await request(app)
.post("/reset").send({ token, password: "new-Passw0rd!x" });
const second = await request(app)
.post("/reset").send({ token, password: "other-Passw0rd!y" });
expect(first.status).toBe(200);
expect(second.status).toBe(400);
});
test("期限切れトークンは拒否される", async () => {
const token = await issueResetToken("alice@example.com", {
expiresAt: new Date(Date.now() - 1000),
});
const res = await request(app)
.post("/reset").send({ token, password: "new-Passw0rd!x" });
expect(res.status).toBe(400);
});期限の長さそのものは、OWASPは「適切な期間」としか書いていません。何分にするかは、メールの到達遅延とリスクを見て自分たちで決める数字です。レビューでは、期限が「設定されていて、検証で使われているか」を見ます。値の妥当性まで機械に判定させないほうが安全です。
リンクの組み立てと、リセット後の処理
OWASPはURLトークン方式で、いくつかの細部を挙げています。
- リセットURLをHostヘッダから組み立てない。固定値にするか、信頼するドメインの一覧と照合する
- URLはHTTPSにする
- リセット画面に
Referrer-Policy: no-referrerを付けて、リファラ経由の漏えいを避ける - URL内のトークンの総当たりを、レート制限などで防ぐ
Hostヘッダの件は、先ほどの悪い例の req.headers.origin がまさに該当します。設定値から組み立てるよう直させたうえで、偽のヘッダを付けた要求のテストを足します。
test("リセットURLは設定済みのベースURLで作られる", async () => {
await seedUser("alice@example.com");
await request(app)
.post("/forgot")
.set("Origin", "https://evil.example")
.send({ email: "alice@example.com" });
await runWorkerOnce();
const body = mail.lastBody();
expect(body).toContain("https://app.example.com/reset?token=");
expect(body).not.toContain("evil.example");
});パスワードを更新した後の扱いも、観点表に足しておきます。OWASPの記述を並べると次のとおりです。
- パスワードを書いたメールは送らず、「リセットされた」ことだけを知らせる
- 自動ログインはしない。通常のログインを通る(認証とセッション処理が複雑になり、脆弱性を入れやすくなるため)
- 既存のセッションを無効にするか、ユーザーに選ばせる
「自動ログインしていないか」は、リセット成功後にセッションCookieが発行されていないことを見るテストで足ります。
expect(first.headers["set-cookie"]).toBeUndefined();トークンにJWTを使う設計もOWASPは許容していますが、「追加の脆弱性を持ち込みうる」とも書いています。その場合は、署名やクレームの検証を別観点で洗う必要があります。手順はJWT検証コードのレビューにまとめています。
要求側のレート制限とロックアウト
要求側(/forgot)には、OWASPは自動送信への対策も求めています。アカウント単位のレート制限やCAPTCHAなどを入れないと、特定のアカウントのメール受信箱を大量の要求で埋められます。実装はRedisによるレート制限の手順が使えます。
ここでレビューが見落としやすいのは、反対方向の誤りです。OWASPは「忘失攻撃を理由にアカウントをロックしてはいけない」と書いています。ロックすると、ユーザー名を知っている第三者が、本人のログインを封じられるからです。
レート制限に達したときに users.locked_until を更新するような実装がないか、観点表の最後の行として検索させます。
reset 関連のコードで、リセット要求の失敗や過多を理由に
users テーブルの状態(locked 系の列)を更新している箇所を探して。方式ごとに観点が変わる: URLトークン・PIN・セキュリティ質問
OWASPはリセットの方式として、URLトークン、PIN、オフライン方式、セキュリティ質問を挙げています。レビューを始める前に、自社の実装がどれにあたるかをClaude Codeに答えさせると、観点表のどの行が効くかが決まります。
- URLトークン: メールで送るリンクにトークンを載せる方式。最も手軽で、この記事の観点表はこれを前提にしています
- PIN: 6〜12桁の数字をSMSなどで送る方式。入力された後は、パスワードのリセットだけを許す限定的なセッションを作ります。桁数が短いぶん、試行回数の制限が効かないと総当たりされます
- セキュリティ質問: 答えが推測されやすいため、単独の手段にしてはいけません。他の方式と併用して、追加の確認として使う位置づけです
どの方式でも、利用者が必ずアカウントを回復できる経路を残すことが求められています。サポートに連絡して本人確認を受ける道も含みます。コードのレビューでは見落とされやすいので、観点表の最終行に「回復できない利用者がいないか」を足しておきます。
指摘を実装に反映するときの注意
Claude Codeの指摘を受け入れるときは、次の点を決めておくと手戻りが減ります。
- 先にテストを追加し、赤になることを確認する(現在の実装が観点に反している証拠になる)
- 直したら緑になることを確認し、他の既存テストも流す
- 指摘が「反する」と言うのにテストが緑なら、指摘の側を疑う
公式の共通ワークフローは、Claudeが作ったPRについて、送る前にレビューし、リスクや考慮事項を挙げさせるよう勧めています。リセット機能のように攻撃を受ける機能では、その確認をテストの形で残しておくと、後の改修で観点が崩れたときにも落ちて気づけます。
観点表に載っていない領域もあります。チートシートには、乗っ取りが疑われるアカウントの復旧(セッションや回復用情報の無効化)の節がありますが、これは通常のリセット実装とは別の運用設計なので、必要ならその節を観点表に追加して同じ手順を回します。ブランチ全体の脆弱性の洗い出しはsecurity-reviewコマンドで別途行えます。
まとめ
パスワードリセットのレビューは、OWASPの記述を「コードのどこを見るか」と「どう判定するか」の表にすることで、Claude Codeに渡せる形になります。plan modeで読み取りだけをさせ、指摘はテストに変えて確かめます。
特に効くのは、応答の一致、トークンの一回性、期限切れの3本のテストです。この3本があれば、ユーザー列挙と使い回しの脆弱性は、実装を書き換えても同じ観点で検査され続けます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。