Valid Token, Wrong Scope: an IDOR via design/date Parameters
This article may contain affiliate links. Its content is not affected by advertising.
In short
A classToken that correctly scopes a class doesn't stop a sibling query parameter from pulling data outside what that screen has ever shown.
Conclusion
A classToken that correctly scopes a class doesn’t stop a sibling query parameter from pulling data outside what that screen has ever shown. The anonymous signage route in キミテラス-v2 (/signage/<classToken>/data) correctly resolved classToken to the right class, but its two other query parameters, ?design= and ?date=, were only format-checked and then passed straight through. Holding the one “right key” (the token) let a caller swap two unrelated parameters and read past what that key was ever meant to unlock: today, on the layout actually registered for that classroom.
Symptom
キミテラス-v2’s signage shows the same screen on a classroom TV and, via a classToken link, on a student’s own phone. The anonymous polling endpoint behind that link accepted two query parameters whose values were only format-validated:
// apps/web/app/(signage)/signage/[classToken]/data/route.ts (before)
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;
// Validate format + that the calendar date exists; invalid falls back to today
// (an invalid calendar date would 500 on the Postgres date comparison).
const date = parseSignageDate(search.get("date"));
const adExempt = isMonitorAdExempt(search.get("classAds"));
// Per-device design (SignageClient carries the initial designPattern via ?design).
// Missing/unknown fails soft to the default.
const payload = await getSignageDisplayData(classToken, date, search.get("design"), adExempt);
// ...
}
date was only checked for being a real calendar date (parseSignageDate), never for being today. design was only checked for being a known pattern name, never for being the pattern actually registered to that classroom. An internal security audit on 2026-10-01 found that, using your own class’s classToken, requesting ?design=pattern2&date=<a past date> returned call records (student names and reasons) and visitor records (names and affiliations) for that classroom, for the entire history, none of which that screen had ever displayed.
Cause
The access control this API relied on had exactly one check: that classToken resolved to the right class. That premise itself is an explicit, accepted design decision (ADR-042): because signage content is physically on display in the classroom, it’s fine to hand a classToken holder the same data the screen shows. What the implementation actually did was widen that premise into “once classToken checks out, trust every other parameter too.”
// apps/web/lib/signage/signage-display.ts (before)
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 and design were only ever supposed to reflect the state of a specific physical device today, not inputs the server should trust from an arbitrary request. But the implementation passed the request’s own date and designParam straight into buildSignagePayloadForClass, right alongside the schoolId/classId that classToken had legitimately resolved. The class-scoping key (classToken) and the two values that were supposed to just mirror a device’s current state (design/date) ended up as independent variables within the same request, and nothing stopped either from being pushed outside the scope the screen actually shows.
Fix
Two pure functions were added, one per parameter, each narrowing the request’s value down to what the screen actually shows — and both anonymous routes (classToken and monitor) were made to go through them.
// 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 clamps to today ±1 day in JST; anything further out falls back to today. (Devices always send today’s date, so the ±1 day is only slack for the date rollover and clock drift between device and server.) design is accepted only when it matches the layout actually set on a device registered to open that specific classToken; any other value — unregistered, a different pattern, garbage — resolves to undefined and the caller falls back to the school default (pattern1). The monitor route goes further still: it stopped reading the request’s ?design at all, replacing it with designForMonitor, which decides purely from the device’s own registered signage_url.
// apps/web/lib/signage/signage-display.ts (after)
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,
);
How a failure to read the registered device URLs should behave also had to differ by caller. On first render (SSR), falling back to the school default and showing something is the right call. On a 5–10 second poll, that same fail-soft behavior causes a flicker: the screen would briefly flip to the default layout and back the moment one lookup failed. So a strictScopeLookup flag was added — only the polling route (the data route) throws on a failed lookup, turning it into a 500, so the device just keeps whatever it already has on screen until the next poll.
Prevention
The tests covering this path pin down the exact attack scenarios they close, by name:
// apps/web/__tests__/signage/anonymous-scope.test.ts
it("falls back to today when the date is 2+ days off (mining past calls/visitors)", () => {
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("rejects a design that doesn't match registration (no switching to a pattern that shows names)", () => {
expect(allowedDesignForClassToken("pattern3", "tokA", urls)).toBeUndefined();
expect(allowedDesignForClassToken("pattern2", "tokB", urls)).toBeUndefined();
});
The test descriptions themselves spell out what they’re closing off — “mining past calls/visitors”, “switching to a pattern that shows names.” Pulling clampAnonymousSignageDate, allowedDesignForClassToken, and designForMonitor out as side-effect-free functions in their own module means the actual claim — a correct classToken doesn’t license every other parameter to go out of scope — is something unit tests can pin down on their own, without anyone having to trace through the calling code. Authenticated routes (the editor’s preview, the monitor wall) call buildSignagePayloadForClass directly and are explicitly out of scope for this constraint, by design.
Frequently asked questions
Q1Why did a valid classToken still leak data from other dates?
Because the date query parameter was only checked for being a real calendar date, not for being today. The token correctly resolved to the right class, but a past date in the same request returned call records and visitor logs that screen had never displayed.
Q2What was the design parameter supposed to do, and how was it abused?
design picks the layout a specific device shows, including which pattern renders visitor names. The classToken route passed the request's own design value straight through, so a token could switch to a layout pattern that no device registered to that class actually used.
Q3Did anyone actually exploit this in production?
No. It was found in an internal security audit on 2026-10-01 and fixed the same day. There is no record of it being used against production.
Q4How was it fixed?
date clamps to today plus or minus one day in JST; anything outside falls back to today. design on the classToken route is accepted only if it matches a registered device's actual layout for that token; the monitor route ignores the request's design and uses the device's own value.
Environment verified
- Next.js ^16.3.7 / React ^19.0.0 (キミテラス-v2 apps/web)
- Found in an internal security audit on 2026-10-01 and fixed the same day (no record of production exploitation)
What this article is based on
- TypeScript file lines 20-41commit 1636418
- TypeScript file lines 214-241commit 1636418
- TypeScript file lines 20-39commit 2e0591c
- TypeScript file lines 1-71commit 2e0591c
- TypeScript file lines 220-258commit 2e0591c
- TypeScript file lines 480-521commit 2e0591c
- TypeScript file lines 1-77commit 2e0591c
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.