Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

採点APIがリクエストボディのuserIdを信用し、他人のプロフィールを書き換えられた

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

採点APIがリクエストボディのuserIdを信用すると、採点者はsubmissionIdに紐づく提出者とは無関係に、別人のプロフィールのレベルを書き換えられる。

採点APIがリクエストボディのuserIdを信用すると、採点者はsubmissionIdに紐づく提出者とは無関係に、別人のプロフィールのレベルを書き換えられる。

結論

ある学習システムの採点API(/api/admin/score)は、採点対象のユーザーIDをDBから求めず、リクエストボディのuserIdフィールドをそのまま信用していた。 採点画面自体は正しいuserIdを表示・送信していたが、サーバー側は「このリクエストのuserIdが、本当にこのsubmissionIdの提出者と一致するか」を一度も確認していなかった。結果として、採点者がリクエストのbodyだけを書き換えれば、任意の他人のプロフィール(学習レベル)を更新し、合否結果メールまで別人宛てに送らせることができた。

症状

採点APIのリクエストスキーマは、submissionIdに加えてlevelとuserIdをクライアントからそのまま受け取る形になっていた。

const ScoreSchema = z.object({
  submissionId: z.string().uuid(),
  verdict: z.enum(['passed', 'failed']),
  feedback: z.string().max(5000).optional(),
  level: z.number().int().min(1).max(47),
  userId: z.string().uuid(),
})

採点確定(verdict: 'passed')の処理では、このuserIdとlevelをそのままDB更新に使っていた。

if (verdict === 'passed') {
  await admin
    .from('profiles')
    .update({ current_level: level })
    .eq('id', userId)
    .lt('current_level', level)
}

さらに合否結果メールの送信先も、同じuserIdから引いたメールアドレスだった。

const { data: userData } = await admin.auth.admin.getUserById(userId)
const email = userData?.user?.email
if (email) {
  await sendResultEmail({ email, level, verdict, feedback })
}

submissionIdによる提出内容の更新(level_submissionsテーブルへのstatus更新)自体はsubmissionIdで絞られているため、どの提出が採点されたかは正しく記録される。だが**「誰のprofiles.current_levelを上げるか」と「誰にメールを送るか」の2箇所は、submissionIdと紐付いた提出者ではなく、bodyのuserIdだけを基準に動いていた**。

原因

採点画面(ScoreForm)は、提出者本人のuserId・levelを正しくpropsとして受け取っていた。

interface Props {
  submissionId: string
  level: number
  userId: string
  criteria: string[]
}

export default function ScoreForm({ submissionId, level, userId, criteria }: Props) {

問題は、これらの値をサーバーから渡された表示用データとしてではなく、クライアントが送信するリクエストの一部として扱っていたことにある。送信処理は受け取ったlevelとuserIdをそのままJSONへ直列化していた。

const res = await fetch('/api/admin/score', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ submissionId, verdict, feedback, level, userId }),
})

このpropsの出どころは、採点画面を描画するサーバーコンポーネント側で、審査待ちの提出を一覧表示するループの中にある。

<AiScoreButton
  submissionId={sub.id}
  level={sub.level}
  userId={sub.user_id}
  content={sub.content}
/>
...
<ScoreForm
  submissionId={sub.id}
  level={sub.level}
  userId={sub.user_id}
  criteria={levelDef?.criteria ?? []}
/>

ここで渡されるsub.level・sub.user_id自体は、DBから取得した正しい値だった。だがクライアントコンポーネントに一度渡された時点で、その値は「サーバーが保証した事実」ではなく「ブラウザが次に送ってくるデータ」に変わる。採点者がブラウザの開発者ツールやAPIクライアントで送信直前のリクエストbodyを書き換えれば、画面に表示されている提出者とは無関係なuserIdをサーバーへ送ることができる。サーバー側はそのuserIdが本当にsubmissionIdの提出者かどうかを検証していなかったため、別人のプロフィールが更新され、別人宛てにメールが送られる。

直し方

修正は、userIdとlevelをリクエストボディから受け取る項目そのものから外し、submissionIdからDBを逆引きして所有者を確定する設計に変えている。

// submissionId からユーザーとレベルを取得(クライアント送信値を信頼しない)
const { data: submission, error: fetchErr } = await admin
  .from('level_submissions')
  .select('user_id, level, status')
  .eq('id', submissionId)
  .single()

if (fetchErr || !submission) {
  return NextResponse.json({ error: '提出が見つかりません' }, { status: 404 })
}
if (submission.status !== 'pending') {
  return NextResponse.json({ error: 'この提出は既に採点済みです' }, { status: 409 })
}

const { user_id: userId, level } = submission

userId・levelはこの時点でDBから取得した値に置き換わり、以降のプロフィール更新・メール送信はすべてこの値を使う。クライアント側のコンポーネントも、もはや使われないlevel・userIdをpropsから外し、リクエストbodyに含めなくなった。

interface Props {
  submissionId: string
  criteria: string[]
}
<AiScoreButton
  submissionId={sub.id}
  level={sub.level}
  content={sub.content}
/>
...
<ScoreForm
  submissionId={sub.id}
  criteria={levelDef?.criteria ?? []}
/>

levelはAiScoreButton側では別の用途(AI採点の表示)に残っているが、userIdはどちらのコンポーネントからも渡されなくなっている。サーバーは「クライアントが何を送ってきたか」ではなく「submissionIdが指す提出が誰のものか」だけを信頼する形になった。

再発防止

根拠のコードには、この種の「クライアントに渡した値を、そのクライアントからのリクエストでも信用してしまう」設計を検知するテストや型レベルのチェックについての記述は無い。同じ修正コミットで、採点済みの提出に対する重複採点を409で拒否する対策も合わせて追加されているが、これは別の問題(二重採点)への対応であり、userIdの信頼境界そのものを検査する仕組みではない。

構造的に見れば、この種の不備が残りやすいのは、サーバーコンポーネントが正しい値を描画に使っているという事実と、クライアントコンポーネントがその値を次のリクエストに使ってよいという判断が、コード上では区別されないためだ。sub.user_idをpropsとして渡す行為自体は表示目的であれば安全だが、同じ値をフォームの送信データに含めた瞬間、それは「サーバーが検証済みの事実」から「クライアントが提示する未検証の入力」に変わる。この境界を越える箇所ごとに、受け取ったIDを信用する前にサーバー側で突き合わせが必要になる。

よくある質問

Q1これは通常のIDOR(認可漏れ)と同じ脆弱性ですか?

典型的なIDORは他人のリソースIDを推測・指定して直接アクセスする形だが、この場合は採点者自身が正しいsubmissionIdを使った状態で、同じリクエストに含むuserIdフィールドだけを別の値に書き換えられる点が異なる。サーバー側がbodyのuserIdをsubmissionIdの所有者と突き合わせていなかったため、両者が無関係でも処理が通ってしまう。

Q2画面には正しいuserIdが表示されていたのに、なぜ書き換え可能だったのですか?

ScoreFormコンポーネントは提出者本人のuserId・levelをpropsとして正しく受け取っていたが、送信時にfetchのbodyへそのまま載せていた。開発者ツールやAPIクライアントで送信前にbodyの値を書き換えれば、画面の表示値とは無関係なuserIdをサーバーに送ることができた。

Q3影響はプロフィールの書き換えだけですか?

根拠のコードでは、合格判定時にprofiles.current_levelをbodyのuserIdで更新する処理と、同じuserIdのメールアドレス宛てに合否結果メールを送る処理の両方がuserIdを信用していた。bodyのuserIdを書き換えると、レベル更新と結果メールの宛先が両方とも別人に対して実行される。

確認した環境

  • Next.js 16.2.10 / @supabase/supabase-js ^2.110.1 / zod ^4.4.3
  • 2026-07-23 に修正

この記事の根拠

  • TypeScriptファイル 6〜12行目コミット 9fba662
  • TypeScriptファイル 55〜61行目コミット 9fba662
  • TypeScriptファイル 63〜67行目コミット 9fba662
  • TypeScriptファイル 6〜11行目コミット 9fba662
  • TypeScriptファイル 23〜28行目コミット 9fba662
  • TypeScriptファイル 128〜144行目コミット 9fba662
  • TypeScriptファイル 33〜47行目コミット 65edda7
  • TypeScriptファイル 6〜9行目コミット 65edda7
  • TypeScriptファイル 128〜141行目コミット 65edda7

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。