認証なしでパスワードを上書きできる API が 4 年潜在していた話 — 発見・調査・即日 hot fix 録
業界 SaaS tasteck で『キャストがログインできない、変えた覚えがないのに』という 1 件の問い合わせから、4 年潜在していたパスワード再発行 API の認証バイパス脆弱性を発見。DB 調査 → QA 再現実験 → hot fix → 本番デプロイ → 被害アカウント救済までのリアル incident report。Build-in-Public 第 8 弾。
第 6 弾 (Stripe webhook 5 日サイレント失敗) を出した翌々日、別の incident が発生しました。
朝、コールセンター事業者経由で店舗様からの問い合わせ。
「キャストがログインできない、パスワードを変えた覚えがないのに」
しかも別店舗からも同じ症状の連絡。「たまに出る現象」と言われていた。今日はこの「たまに」を本気で潰しに行く日になりました。
Build-in-Public 第 8 弾、4 年潜在していたセキュリティ脆弱性の発見〜即日 hot fix までのリアル incident report です。
朝、調査開始
DB を直接確認。該当アカウントの password カラム (bcrypt hash) は確かにその日朝に更新されていた。「変えた覚えない」のに hash は変わっている。
最初に疑ったのは スタッフ管理画面のキャスト編集機能で password が意図せず書き換わるバグ。フロントエンドの form state が裏で password を保持していて、保存時に送信される、というありがちなパターン。
QA 環境で再現実験:
- テスト用キャストを 1 名選び、編集モーダルを開く
- 開発者ツールで DOM を確認 → password 入力欄が DOM レベルで存在しない
- 何もせず保存ボタン → DB の password hash 一致 (変化なし)
- 表示名を変更して保存 → Network タブでリクエスト body 確認 →
passwordフィールド含まれていない - DB の password hash 再度一致
→ 編集モーダル経由ではない。別経路でしか起きない。
過去履歴を遡る
別店舗のキャスト・スタッフを調べると、同じメールアドレスで 2021 年と 2023 年にも「ログインできない → admin が同じメアドで再発行」のループを 2 回繰り返していた ことが DB から判明。
| 年月 | 出来事 |
|---|---|
| 2021-12 | 同 email で staff 1 人が「ログイン不能」→ admin が新 staff レコード作成 |
| 2023-09 | 同 email で staff 別の 1 人が同事象 → admin が新レコード作成 |
| 2026-05 | 今回 (調査して判明) |
つまり慢性的な問題。少なくとも 4 年は続いている。
真因発見
サーバーコードを精査した結果、パスワード再発行 API の controller / service に以下の構造を発見:
// controller (cast)
@Post(`/passwordReset`)
async passwordReset(@Body() req: { email: string; password: string }) {
return await this.connection.transaction(async (entityManager) => {
return await this.service.passwordReset(entityManager, req);
});
}
// service
async passwordReset(entityManager, req: { email: string; password: string }) {
const casts = await ...createQueryBuilder("cast")
.where("cast.email = :email", { email: req.email })
.getMany();
if (!casts.length) throw new HttpException("...", 400);
const password = await bcrypt.hash(req.password, 10);
// 全 cast の password 上書き
...
}
ポイント:
- 認証ガードなし (Auth Decorator なし)
resetToken検証なし- リクエスト body に
email + passwordを入れて POST するだけで、その対象の password を任意に書き換え可能
メール経由で配信されるリセット URL の ?token=... は、フロントエンドの findByResetToken で email を取得するためだけに使用。サーバー側では token を一切検証していなかった。
→ メールアドレスを知っている第三者なら誰でも、API を直接叩いて任意アカウントの password を書き換えられる状態が、少なくとも 4 年放置されていた。
業界関係のメールアドレスは業者間で流通しているので、攻撃ベクトルとして十分現実的。
hot fix の設計
完全な恒久対応 (controller の signature を { resetToken, password } に変更 + frontend 修正) は、フロントの cast-app + staff-app 2 つのリビルド + CloudFront invalidation が必要で、緊急デプロイにはやや重い。
最小手数で塞ぐ方針:
// service.ts (cast)
async passwordReset(entityManager, req: { email: string; password: string }) {
const casts = await ...createQueryBuilder("cast")
.where("cast.email = :email", { email: req.email })
.andWhere("cast.reset_token IS NOT NULL") // ← この一行を追加
.getMany();
...
}
// service.ts (staff) も同じく追加
.andWhere("staff.reset_token IS NOT NULL")
これで:
- ✅ 「パスワードを忘れた」フローで
sendEmailを呼んでいない直叩きは弾かれる (reset_tokenがnullのまま) - ✅ リセット完了後は
resetToken=nullクリアで連続改ざんも防げる - ✅ 正規フロー (フロント
sendEmail→ メール → URL → 新パス入力) はフロント無修正で通る - ✅ frontend rebuild 不要、サーバーのみのデプロイで完結
QA E2E テスト
QA 環境にデプロイして 4 ケース検証:
| Test | 期待 | 結果 |
|---|---|---|
| 直叩き passwordReset (cast、token 無し) | 400 | ✅ 400 |
| 直叩き passwordReset (staff、token 無し) | 400 | ✅ 400 |
| 正規フロー (sendEmail → passwordReset) | 201 | ✅ 201 |
| 同じ token で二重叩き (token=null になった後) | 400 | ✅ 400 |
すべて期待通り。本番デプロイへ。
本番デプロイ + 被害アカウント救済
本番 EC2 (Node.js + PM2 + NestJS) に修正コード配置 → ビルド → pm2 restart api。再起動 5 秒で online、92 MB 安定。
本番でも直叩き 400 を確認 → 完全閉鎖。
ただし、攻撃を受けたアカウントは 既に DB 上の password が attacker の値で書き換わっている ため、本人にはログインできない状態が続く。admin script で安全な仮 password を強制発行 → コールセンター事業者経由で本人に直接お伝え → 本人ログイン後に必ず本人ご希望のパスワードに変更を依頼する流れ。
学び
1. 「散発的に起きる」は仕様ではなく、調査されていないだけ
「たまに出る現象」と片付けて毎回 admin 再発行で対応していた事象が、実は 4 年放置されていたセキュリティ脆弱性だった。お客様 1 人の本音 (「変えた覚えがない」) を真剣に受け止めるのが、こういう時の入り口。同じパターンの問い合わせを「またあれだろう」とパッケージング処理しないこと。
2. PR 計画 < 緊急修復
その日は受託・コンサル受付ページの追加 reach + 業界キーマンへの outreach + 別 blog の準備、を予定していた。全部キャンセル。当然。修復が終わったあと、こうやって Build-in-Public log として公開するほうが、計画通りの PR より transparent でもある。
3. 業界 SaaS 特有のリスク管理
業界関係のメールアドレスが業者間で流通している前提で運用しないといけない。仕組み上「メール所有者だけが操作できる」設計を徹底する。
「サーバー側で resetToken を検証していないが、フロントが email を取得するときに使うから OK」 — この種の 暗黙の信頼が、4 年放置の温床になった。設計レベルで「攻撃者は API を直叩きする」前提を崩さない。
4. 最小手数の hot fix 設計
完全な恒久対応 (frontend 修正含む) は時間がかかる。緊急時は service 層の 1 行ガード追加で攻撃面を塞ぐ選択は、本番影響時間を最小化する上で正解だった。恒久対応は別途、時間をかけて。
残課題
- 完全な恒久対応: controller signature を
{ resetToken, password }に変更し、frontend (cast-app + staff-app) も resetToken を送るよう修正。これで「sendEmail 経由で attacker が直叩きする可能性」を完全に閉鎖 - 過去 4 年の被害アカウント特定: PM2 ログ archive がない (current
api-out.log1 ファイル 4.7 GB のみ) ので、過去攻撃の対象アカウントの全数調査は再構築不可。今回の店舗様分は救済済 - WAF / rate limit 導入: 1 IP からの passwordReset 連打防止、攻撃検出
- ALB access log 有効化:
prd-notel-apiのaccess_logs.s3.enabledがfalseだったので、今後の調査用に有効化
技術詳細を avoid したい case のために、上のまとめは「メールアドレスを知っていれば API 直叩きで他人の password を書き換えられる構造があった、即日修正済」 だけ取り出してください。
Stripe webhook の次は API 認証バイパス。SaaS 8 年運用、こういう日があるからこそ Build-in-Public で外に出す価値もある、と感じます。
関連記事:
- Build-in-Public 第 7 弾: PR から monetize へ — /work 公開記
- Build-in-Public 第 6 弾: Stripe webhook 5 日サイレント失敗 incident
- Build-in-Public 第 5 弾: AI 自律 SaaS プロモ 10 日経過
- 同様の API セキュリティ / 認証設計のレビューご相談: tasteck.tech/work で受付中 (¥30,000 〜)