Rebounder Tech Blog

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

tokenは合っているのに、design/dateのquery parameterで画面外データが取れるIDOR

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

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

結論

classTokenでクラスを確定する匿名APIでも、design/dateのクエリパラメータをリクエストの言い値のまま使うと、画面に一度も出ていない過去日のデータまで取得できる。

結論

classTokenでクラスを確定する匿名APIでも、design/dateのクエリパラメータをリクエストの言い値のまま使うと、画面に一度も出ていない過去日のデータまで取得できる。 キミテラスv2の匿名サイネージ経路(/signage/<classToken>/data)は、classTokenでその生徒のクラスを正しく確定していたが、?design=と?date=の2つのクエリパラメータは値の形式しか検証せず、そのまま下流のクエリに渡していた。「正しい鍵(token)」を持つ人が、鍵とは無関係な2つのパラメータだけを書き換えることで、鍵が本来開けるはずの範囲(今日・登録済みデザイン)の外側までデータを取り出せる状態になっていた。

症状

キミテラスv2のサイネージは、教室のテレビに表示している内容と同じものを、生徒が自分のスマホからも見られるようclassToken付きのURLを配っている。この匿名経路のポーリング先APIは、形式だけは検証した2つのクエリパラメータを受け取っていた。

// apps/web/app/(signage)/signage/[classToken]/data/route.ts(修正前)
export async function GET(
  request: Request,
  context: { params: Promise<{ classToken: string }> },
): Promise<NextResponse> {
  const { classToken } = await context.params;
  const search = new URL(request.url).searchParams;
  // 形式 + 実在暦日を検証し無効は今日へフォールバック(無効暦日が pg date 比較で 500 になるのを防ぐ)。
  const date = parseSignageDate(search.get("date"));
  const adExempt = isMonitorAdExempt(search.get("classAds"));
  // 端末別デザイン(SignageClient が初期 designPattern を ?design で引き継ぐ)。未指定/未知は既定へ fail-soft。
  const payload = await getSignageDisplayData(classToken, date, search.get("design"), adExempt);
  // ...
}

dateは「暦日として実在するか」の形式検証(parseSignageDate)だけで、「今日かどうか」は見ていない。designは「パターン名として妥当か」だけで、「その教室に登録されているデザインかどうか」は見ていない。2026-10-01の社内セキュリティ監査で、自分のクラスのclassTokenのまま?design=pattern2&date=過去日を指定すると、その教室の画面には一度も表示されていない呼び出し(生徒の氏名・理由)と来校者(氏名・所属)が、全期間ぶん返ってくることが分かった。

原因

このAPIが前提にしていたアクセス制御は「classTokenが正しいクラスを指すこと」の1点だけだった。この前提自体はADR-042が「盤面に出ている内容は教室で物理的に公開済みなので、classTokenを持つ人へ盤面と同じデータを返すのは許容する」として明示的に受け入れている設計判断であり、ここは変えていない。問題は実装が、この前提を「classTokenが正しければ、以降のパラメータは何でも信じてよい」にまで広げてしまっていたことだった。

// apps/web/lib/signage/signage-display.ts(修正前)
export async function getSignageDisplayData(
  classToken: string,
  date: string,
  designParam?: unknown,
  adExempt = false,
): Promise<SignagePayload | null> {
  const cls = await resolveSignageClass(classToken);
  if (!cls) {
    return null;
  }
  const now = adExempt ? undefined : new Date();
  const fork = tenantReadFork(getDb(), { schoolId: cls.schoolId });
  return await buildSignagePayloadForClass(
    fork,
    cls.schoolId,
    cls.classId,
    date,
    designParam,
    undefined,
    now,
  );
}

dateとdesignは、本来は「この教室の端末が今日たまたまどの状態か」を伝えるだけの値で、サーバー側がリクエストから信じてよい入力ではなかった。ところが実装では、classTokenから確定したschoolId・classIdと並んで、リクエストのdate・designParamがそのままbuildSignagePayloadForClassに渡っていた。「classTokenというクラス固定の鍵」と「design/dateという本来は端末側の状態を映すだけの値」が、同じリクエストの中で独立に動かせる変数になっており、盤面に出る範囲(今日・登録済みデザイン)を超えた値を指定されても、それを拒む層がどこにも無かった。

直し方

designとdateそれぞれについて、「盤面に実際に出る範囲」へ絞り込む専用の純関数を新設し、匿名経路の2箇所(classToken経路・monitor経路)から必ず通すようにした。

// apps/web/lib/signage/anonymous-scope.ts
export const ANON_SIGNAGE_DATE_SLACK_DAYS = 1;

export function clampAnonymousSignageDate(date: string, now: Date = new Date()): string {
  const today = jstDateString(now);
  const dayMs = 86_400_000;
  const diffDays = Math.round(
    (Date.parse(`${date}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / dayMs,
  );
  return Number.isFinite(diffDays) && Math.abs(diffDays) <= ANON_SIGNAGE_DATE_SLACK_DAYS
    ? date
    : today;
}

export function allowedDesignForClassToken(
  designParam: unknown,
  classToken: string,
  signageUrls: readonly string[],
): SignageDesignPattern | undefined {
  if (!isSignageDesignPattern(designParam)) return undefined;
  for (const url of signageUrls) {
    if (extractSignageClassToken(url) !== classToken) continue;
    if (getDesignPatternFromUrl(url) === designParam) return designParam;
  }
  return undefined;
}

dateはJSTの今日を基準に±1日の範囲外なら今日へ倒す。端末は常に今日の日付を送ってくるので、±1日は日付の変わり目と端末時計のずれを吸収するための余裕でしかない。designは「リクエストが指定した値」ではなく「そのclassTokenを開いている登録端末のsignage_urlに実際に設定されているデザイン」と一致するときだけ採用し、それ以外はundefinedとして呼び出し側を学校既定(pattern1)に倒す。monitor経路はさらに踏み込み、リクエストの?design自体を見ずに端末のsignage_urlだけで決めるdesignForMonitorに置き換えた。

// apps/web/lib/signage/signage-display.ts(修正後)
const signageUrls = await fork((tx) => listSchoolSignageUrls(tx)).catch((e: unknown) => {
  if (strictScopeLookup) throw e;
  return [] as string[];
});
return await buildSignagePayloadForClass(
  fork,
  cls.schoolId,
  cls.classId,
  clampAnonymousSignageDate(date),
  allowedDesignForClassToken(designParam, classToken, signageUrls),
  undefined,
  now,
);

登録端末URLの読み取りに失敗したときの倒し方も、呼び出し元によって変えた。初回表示(SSR)は学校既定へfail-softに倒して何か出す方がよいが、5〜10秒ごとのポーリングで同じfail-softを使うと、読み取りが一瞬失敗しただけで盤面が既定レイアウトに切り替わってすぐ戻る、というチラつきが起きる。そこでstrictScopeLookupという引数を足し、ポーリング経路(dataルート)だけは読み取り失敗を例外として投げて500にし、端末側は直前の盤面を保持したまま次のポーリングを待つようにした。

再発防止

この経路を担保するテストは、ふさいだ攻撃のシナリオそのものを1件1件固定する形で書かれている。

// apps/web/__tests__/signage/anonymous-scope.test.ts
it("2 日以上離れた日(過去の呼び出し・来校者の掘り出し)は今日に倒す", () => {
  expect(clampAnonymousSignageDate("2026-09-29", NOW)).toBe("2026-10-01");
  expect(clampAnonymousSignageDate("2026-05-01", NOW)).toBe("2026-10-01");
  expect(clampAnonymousSignageDate("2027-10-01", NOW)).toBe("2026-10-01");
});

it("登録と違うデザインの指定は捨てる(氏名ブロックを持つ別パターンへの切替を許さない)", () => {
  expect(allowedDesignForClassToken("pattern3", "tokA", urls)).toBeUndefined();
  expect(allowedDesignForClassToken("pattern2", "tokB", urls)).toBeUndefined();
});

テストのタイトル自体に「過去の呼び出し・来校者の掘り出し」「氏名ブロックを持つ別パターンへの切替」と、ふさいだ攻撃の内容がそのまま書かれている。clampAnonymousSignageDate・allowedDesignForClassToken・designForMonitorを副作用の無い純関数としてanonymous-scope.tsに分離したことで、「classTokenという鍵は正しいが、それ以外のパラメータが盤面に出る範囲を超えている」という判定が、呼び出し側のコードを読まなくても単体テストだけで保証できる形になった。認証済みの経路(エディタのプレビュー・モニタの壁)はbuildSignagePayloadForClassを直接呼ぶ別経路のため、この範囲制限の対象外であることも実装上明示されている。

よくある質問

Q1なぜclassTokenが正しいのに、過去日のデータまで見えたのですか?

/signage/<token>/dataのdateパラメータをそのまま信じていたため。tokenは正しいクラスを指すが、dateには任意の過去日を指定できた。過去日を指定すると、その教室の画面には一度も表示されていない呼び出し記録や来校者記録まで返っていた。

Q2designパラメータは何のためにあり、どう悪用できたのですか?

designは端末ごとの盤面レイアウト(pattern1/pattern2など)を切り替える指定。classToken経路はこのdesignパラメータもリクエストの言い値のまま使っていたため、自分の教室には登録されていない別パターンに切り替えて表示させることができた。

Q3この経路で実際に第三者が情報を取得した事実はありますか?

いえ。2026-10-01の社内セキュリティ監査で発見され、本番で悪用された記録は無い。発見から修正まで同日中に完了している。

Q4直し方はどう変えましたか?

dateはJSTの今日±1日の範囲外なら今日に倒すclampAnonymousSignageDateを追加した。designはclassToken経路では、そのtokenを開いている登録端末のsignage_urlと一致する指定だけを採るallowedDesignForClassTokenに、monitor経路ではリクエストのdesignを使わず端末のsignage_urlから決めるdesignForMonitorに変えた。

確認した環境

  • Next.js ^16.3.7 / React ^19.0.0(キミテラス-v2 apps/web)
  • 2026-10-01 の社内セキュリティ監査で発見・同日中に修正(本番で悪用された記録なし)

この記事の根拠

  • TypeScriptファイル 20〜41行目コミット 1636418
  • TypeScriptファイル 214〜241行目コミット 1636418
  • TypeScriptファイル 20〜39行目コミット 2e0591c
  • TypeScriptファイル 1〜71行目コミット 2e0591c
  • TypeScriptファイル 220〜258行目コミット 2e0591c
  • TypeScriptファイル 480〜521行目コミット 2e0591c
  • TypeScriptファイル 1〜77行目コミット 2e0591c

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