Rebounder Tech Blog

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

adIdはUUID形式でしか検証せず、実在照合をしないと到達数ビーコンは水増しできる

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

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

結論

UUID形式が正しいことと、そのIDが実在して認可された対象であることは別物で、形式検証だけでは実在照合の代わりにならない。

結論

UUID形式のチェックは「文字列の形」しか見ていない。認証済みのクライアント(有効なclassTokenを持つ端末)が送るpayload内のIDであっても、それが実在し、今その文脈で有効な対象かどうかは別途照合しないと、形式だけ正しい任意のIDを通してしまう。 サイネージ広告の到達数ビーコンがこの形で、実際には発生していない到達イベントを記録できる状態になっていた。

症状

サイネージ端末は、画面に表示した広告の到達(view/tap)を記録するために、有効なclassTokenを使ってビーコンを送ります。広告が表示されているときは、どの広告が表示されていたかを示すadIdをpayloadに含めて送信します。

このビーコンを受け取る側は、classTokenの有効性と、adIdが送られてきた場合はそれがUUID形式になっているかどうかを検証していました。トークンが有効で、adIdの文字列がUUIDの形をしていれば、そのままイベントとしてDBに記録されます。

この状態では、トークン自体は正規のものでも、adIdに実在しない広告のUUIDや、別のクラスで使われている広告のUUIDを入れて送っても、形式検証は通過し、イベントが記録されてしまいます。広告主への到達数報告はこのイベント集計から作られるため、本来発生していない到達が数として積み上がる余地がありました。

原因

adIdの検証は次のような、文字列の形だけを見るチェックでした。

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// ...
if (raw.adId != null) {
  // 表示中の広告 (effective_ads_per_class.ad_id) の uuid。広告主の到達数集計用。
  if (typeof raw.adId !== "string" || !UUID_RE.test(raw.adId)) {
    return { ok: false, message: "adId が不正です。" };
  }
  payload.adId = raw.adId;
}

コメントにある通り、本来このadIdは「表示中の広告」、つまりそのクラスの実効広告集合(effective_ads_per_class)に実在するIDであることが前提でした。しかし実装が検証していたのは正規表現による形式だけで、「実際にそのクラスで今表示され得る広告か」という実在性・有効性は確認していませんでした。

記録処理自体も、トークンから解決したschoolIdだけを使ってテナント文脈を張り、adIdの値はそのままpayloadとしてイベントに書き込むだけでした。

async function resolveSchoolId(classToken: string): Promise<string | null> {
  // ...
  const resolved = await resolveMagicLink(getDb(), hashToken(classToken));
  return resolved ? resolved.schoolId : null;
}

export async function recordSignageEvent(
  classToken: string,
  raw: EventIngestInput,
): Promise<EventIngestResult> {
  const v = validateEventInput(raw);
  if (!v.ok) {
    return { ok: false, reason: "invalid" };
  }
  const schoolId = await resolveSchoolId(classToken);
  if (!schoolId) {
    return { ok: false, reason: "gone" };
  }
  await withTenantContext(getDb(), { schoolId }, async (tx: TenantTx) => {
    await tx.insert(events).values({
      schoolId,
      contentId: v.value.contentId,
      type: v.value.type,
      payload: v.value.payload,
    });
  });
  return { ok: true };
}

トークンの検証(本人が本当にそのクラスの端末か)と、adIdの実在照合(送られてきたIDが本当にそのクラスで表示され得る広告か)は、別の問題です。前者が正しく機能していても、後者を省略すれば、認証された相手が任意の値を送り込む余地が残ります。「認証済みである」ことと「送ってきた値が正しい」ことを取り違えると、このような検証漏れが生まれます。

直す

トークンの解決結果をschoolIdだけでなくclassIdまで含めて持つように変更し、adIdがpayloadに含まれる場合だけ、RLSのテナント文脈の中で実効広告を問い合わせて実在照合するようにしました。

async function resolveTenant(
  classToken: string,
): Promise<{ schoolId: string; classId: string } | null> {
  // ...
  const resolved = await resolveMagicLink(getDb(), hashToken(classToken));
  return resolved ? { schoolId: resolved.schoolId, classId: resolved.classId } : null;
}

export async function recordSignageEvent(
  classToken: string,
  raw: EventIngestInput,
): Promise<EventIngestResult> {
  const v = validateEventInput(raw);
  if (!v.ok) {
    return { ok: false, reason: "invalid" };
  }
  const tenant = await resolveTenant(classToken);
  if (!tenant) {
    return { ok: false, reason: "gone" };
  }

  const adId = typeof v.value.payload.adId === "string" ? v.value.payload.adId : null;

  return await withTenantContext(getDb(), { schoolId: tenant.schoolId }, async (tx: TenantTx) => {
    if (adId !== null) {
      const ads = await getEffectiveAdsForClass(tx, tenant.classId);
      if (!ads.some((ad) => ad.adId === adId)) {
        // spoof / stale な adId。events を書かず invalid に倒す (count 水増し防止)。
        return { ok: false, reason: "invalid" } as const;
      }
    }
    await tx.insert(events).values({
      schoolId: tenant.schoolId,
      contentId: v.value.contentId,
      type: v.value.type,
      payload: v.value.payload,
    });
    return { ok: true } as const;
  });
}

実在照合にはeffective_ads_per_class(security_invokerビュー)を使っています。これは広告の表示そのものに既に使っていたテナント文脈内の問い合わせで、記録時にも同じ仕組みを流用しているだけなので、新しい権限や別経路の接続は増えていません。adIdがそのクラスの実効広告集合に無ければ、イベントは書き込まずにinvalidとして拒否します。

adIdを含まない一般的なview/tap(広告と無関係な画面の到達記録)は、この実在照合の対象外のままです。広告の到達を主張していないイベントにまで照合を広げる必要はなく、従来どおり記録されます。

// L-1: 実効広告に無い adId は invalid で INSERT しない (到達数の水増し防止)
getEffectiveAdsForClass.mockResolvedValue([{ adId: "99999999-9999-4999-8999-999999999999" }]);
const res = await recordSignageEvent("THETOKEN", { type: "view", adId: AD_ID });
expect(res).toEqual({ ok: false, reason: "invalid" });
expect(captured).toHaveLength(0);
// L-1: adId 不在の一般 view は実在照合をスキップし従来どおり INSERT
const res = await recordSignageEvent("THETOKEN", { type: "view", clientId: CLIENT_ID });
expect(res).toEqual({ ok: true });
expect(getEffectiveAdsForClass).not.toHaveBeenCalled();

再発しない形にする

この種のバグは「認証」と「個々の値の認可」を一緒にしてしまうと起きます。classTokenの検証は「このクライアントは本当にこのクラスの端末か」という認証で、adIdの検証は本来「このクライアントが今送ってよい値はこれか」という認可でした。トークンが有効であることをもって、payload内のあらゆる値も信頼してよいと扱ってしまったのが実態です。

再発防止として見るべきなのは、他にも「形式だけを正規表現やUUIDパターンで検証していて、実在性や有効性を照合していない入力」が無いかどうかです。形式検証はエラーメッセージを早く出すためには有効ですが、それ自体は認可の代わりにはなりません。認証済みの相手からの入力であっても、その入力が指す対象が今の文脈で有効かどうかは、別に確認する必要があります。

よくある質問

Q1classTokenを持っていない第三者からも水増しできるのですか?

できません。ビーコンを送るには有効なclassTokenが前提で、トークンの検証自体は従来どおり行われています。問題は、トークンを正しく持っている端末が、payload内のadIdに任意のUUIDを入れても通ってしまっていたことです。認証の有無ではなく、認証済みの相手に何を送らせてしまうかという認可の範囲の話です。

Q2UUID形式のチェックだけでは何が足りないのですか?

UUID形式のチェックは「文字列が8-4-4-4-12の16進数の並びになっているか」しか見ていません。そのIDが実際にどの広告を指し、しかも今そのクラスに表示されている広告として有効かどうかは別の情報で、形式が正しいだけのランダムなUUIDでも検証を通過してしまいます。

Q3なぜ実在照合をイベント記録のたびに行っても問題にならないのですか?

実在照合はeffective_ads_per_classという、当該クラスの実効広告だけを返すビューへの問い合わせです。もともとRLSのテナント文脈(withTenantContext)の中で広告を表示する際に使っている仕組みで、ビーコン記録の経路でも同じ文脈の中から呼ぶだけなので、追加の接続や権限は要りません。

Q4adIdを付けない一般的なview/tapイベントにも実在照合は必要ないのですか?

必要ありません。adIdが無いイベントは特定の広告の到達を主張していないため、水増しの対象になりません。実在照合はpayloadにadIdが含まれている場合だけ行い、含まれていない場合は従来どおりスキップすることで、広告と関係ない一般のview/tap記録には影響を与えない形にしています。

確認した環境

  • Next.js ^16.3.8 / Drizzle ORM ^0.45.2 / postgres ^3.4.5 / Cloud SQL for PostgreSQL 16
  • 2026-05-31 修正(PR #263 ReviewerのL-1 follow-up、PR #265・#272)

この記事の根拠

  • TypeScriptファイル 103〜110行目コミット dd67486
  • TypeScriptファイル 129〜153行目コミット dd67486
  • TypeScriptファイル 137〜174行目コミット e75693c
  • TypeScriptファイル 165〜192行目コミット e75693c

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