tasteck
ブログ一覧に戻る
アップデート

認証なしでパスワードを上書きできる 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. テスト用キャストを 1 名選び、編集モーダルを開く
  2. 開発者ツールで DOM を確認 → password 入力欄が DOM レベルで存在しない
  3. 何もせず保存ボタン → DB の password hash 一致 (変化なし)
  4. 表示名を変更して保存 → Network タブでリクエスト body 確認 → password フィールド含まれていない
  5. 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_tokennull のまま)
  • ✅ リセット完了後は 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.log 1 ファイル 4.7 GB のみ) ので、過去攻撃の対象アカウントの全数調査は再構築不可。今回の店舗様分は救済済
  • WAF / rate limit 導入: 1 IP からの passwordReset 連打防止、攻撃検出
  • ALB access log 有効化: prd-notel-apiaccess_logs.s3.enabledfalse だったので、今後の調査用に有効化

技術詳細を avoid したい case のために、上のまとめは「メールアドレスを知っていれば API 直叩きで他人の password を書き換えられる構造があった、即日修正済」 だけ取り出してください。

Stripe webhook の次は API 認証バイパス。SaaS 8 年運用、こういう日があるからこそ Build-in-Public で外に出す価値もある、と感じます。


関連記事:

30日無料トライアル実施中

現場を、もっと軽やかに。
tasteck で始めましょう。

お申込みは 3 分で完了。カード登録なし、電話番号認証のみ。

  • カード登録なしで開始
  • データ移行サポート無料
  • 全機能 30 日間お試し