From e244031f0c1ed1abeb9f798ab49bdfeeba9e9989 Mon Sep 17 00:00:00 2001 From: Rachit Date: Sun, 1 Nov 2026 01:47:45 +0100 Subject: [PATCH] feat(web): guard the voice demo route against bots The only route that spends money (Muse Voice Transcribe) now: - answers only this site's pages (Origin must match the host); - is rate-limited per visitor by the Vercel firewall rule 'voice-transcribe' (@vercel/firewall 1.2.6, Apache-2.0), with a per-instance fallback if the rule isn't set up or can't be reached; - never keys on X-Forwarded-For, which a client can write; - has a daily cap (VOICE_DAILY_CAP, default 300) and an off switch (VOICE_DEMO=off). Each refusal has a visitor message. Guards are tested on their own. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++ THIRD_PARTY.md | 1 + apps/web/app/api/voice/transcribe/route.ts | 39 +++++++---- apps/web/e2e/lib.spec.ts | 42 ++++++++++++ apps/web/e2e/site.spec.ts | 24 +++++-- apps/web/i18n/dictionaries/en.ts | 3 + apps/web/lib/voice-guard.ts | 76 ++++++++++++++++++++++ apps/web/package.json | 1 + pnpm-lock.yaml | 9 +++ 9 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 apps/web/lib/voice-guard.ts diff --git a/README.md b/README.md index a0af6ed..cf278c3 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,10 @@ Measured, dated, and recorded in `docs/verification.md`. Nothing below is estima ### 5. Hosted version (optional) - If a hosted copy is linked at submission, it runs in replay mode: no account and no keys needed. +- The website's "Ask by voice" is the only part that calls a paid model. It answers only the site's own pages, is + rate-limited per visitor by a Vercel firewall rule (`voice-transcribe`, via `@vercel/firewall`), has a daily cap + (`VOICE_DAILY_CAP`, default 300), and can be switched off at once with `VOICE_DEMO=off`. The preset questions always + work. - Everything also runs locally from the steps above. ### 6. Environment variables diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 5807a11..1c4255a 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -40,6 +40,7 @@ Direct dependencies only. Licences below are as published by each project. | What | Used for | Licence | |---|---|---| | Next.js | Web framework | MIT | +| `@vercel/firewall` 1.2.6 | The voice demo route asks Vercel's firewall rule `voice-transcribe` whether a visitor is over the limit | Apache-2.0 (npm `license` field, checked 2026-11-01). Used as an npm dependency. | | React | UI | MIT | | three.js (`three` 0.186.1) | Point-cloud rendering in the 3D viewer | MIT | | react-three-fiber (`@react-three/fiber` 9.8.2) | React renderer for three.js (`apps/web/components/viewer/`) | MIT | diff --git a/apps/web/app/api/voice/transcribe/route.ts b/apps/web/app/api/voice/transcribe/route.ts index ec76f25..81ace75 100644 --- a/apps/web/app/api/voice/transcribe/route.ts +++ b/apps/web/app/api/voice/transcribe/route.ts @@ -5,22 +5,33 @@ * * Request format from the Muse docs: multipart with a JSON "request" part (no filename) and an * "audio" part; Authorization: Bearer ; model muse-voice-transcribe-1.0. + * + * It spends money, so it is guarded (lib/voice-guard.ts): an off switch, this site's pages only, the Vercel firewall + * rule `voice-transcribe` (5 a minute per visitor, set in the dashboard), and a daily cap. */ +import { checkRateLimit } from "@vercel/firewall"; +import { clientKey, DailyCap, dailyCapFrom, sameSite, voiceDisabled, WindowLimit } from "@/lib/voice-guard"; + const MODEL = "muse-voice-transcribe-1.0"; const MAX_BYTES = 1_500_000; // about 30s of 24 kHz mono; the recorder stops at 15s -const LIMIT = 10; // requests per minute per address, to protect the key -const WINDOW_MS = 60_000; +const ON_VERCEL = process.env.VERCEL === "1"; +const fallbackLimit = new WindowLimit(5, 60_000); // if the firewall rule can't be checked +const dailyCap = new DailyCap(dailyCapFrom(process.env)); -const recent = new Map(); - -function rateLimited(address: string) { - const now = Date.now(); - const hits = (recent.get(address) ?? []).filter((t) => now - t < WINDOW_MS); - hits.push(now); - recent.set(address, hits); - if (recent.size > 5000) recent.clear(); // keep memory bounded - return hits.length > LIMIT; +/** The Vercel firewall rule decides when it can; otherwise this instance's own count does. */ +async function rateLimited(request: Request): Promise { + if (ON_VERCEL) { + try { + const { rateLimited, error } = await checkRateLimit("voice-transcribe", { request, timeout: 1_500 }); + if (!error) return rateLimited; + if (error === "blocked") return true; + // "not-found": the rule isn't set up in the dashboard yet; fall through to the local count + } catch { + // the firewall couldn't be asked: fall through rather than refuse everyone + } + } + return fallbackLimit.over(clientKey(request.headers, ON_VERCEL)); } // The documented host. META_MUSE_BASE_URL in .env points at a different service, which doesn't @@ -51,14 +62,16 @@ function readTranscript(data: unknown): string { } export async function POST(request: Request) { + if (voiceDisabled(process.env)) return Response.json({ error: "disabled" }, { status: 503 }); const key = process.env.META_MUSE_API_KEY; if (!key) return Response.json({ error: "not_configured" }, { status: 503 }); + if (!sameSite(request.headers)) return Response.json({ error: "forbidden" }, { status: 403 }); if (!request.headers.get("content-type")?.startsWith("audio/wav")) { return Response.json({ error: "wrong_format" }, { status: 415 }); } - const address = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local"; - if (rateLimited(address)) return Response.json({ error: "rate_limited" }, { status: 429 }); + if (await rateLimited(request)) return Response.json({ error: "rate_limited" }, { status: 429 }); + if (dailyCap.reached()) return Response.json({ error: "daily_limit" }, { status: 429 }); const audio = await request.arrayBuffer(); if (audio.byteLength > MAX_BYTES) return Response.json({ error: "too_long" }, { status: 413 }); diff --git a/apps/web/e2e/lib.spec.ts b/apps/web/e2e/lib.spec.ts index 8dc156e..c45ba12 100644 --- a/apps/web/e2e/lib.spec.ts +++ b/apps/web/e2e/lib.spec.ts @@ -5,6 +5,7 @@ import { en } from "../i18n/dictionaries/en"; import { brailleCells, matrixDots, wordWidth } from "../lib/braille"; import { oncePerPass } from "../lib/once-per-pass"; import { PIXEL_GRID, PIXEL_ICON_NAMES, pixelRects } from "../lib/pixel-icons"; +import { clientKey, DailyCap, dailyCapFrom, sameSite, voiceDisabled, WindowLimit } from "../lib/voice-guard"; /** Pure logic and content checks: no browser needed. */ @@ -78,3 +79,44 @@ test.describe("story script", () => { times.slice(1).forEach((t, i) => expect(t - times[i], `gap before line ${i + 2}`).toBeGreaterThanOrEqual(0.6)); }); }); + +test.describe("voice route guards", () => { + test("the off switch", () => { + expect(voiceDisabled({ VOICE_DEMO: "off" })).toBe(true); + expect(voiceDisabled({ VOICE_DEMO: "OFF" })).toBe(true); + expect(voiceDisabled({})).toBe(false); + expect(voiceDisabled({ VOICE_DEMO: "on" })).toBe(false); + }); + + test("only this site's own pages may call it", () => { + const h = (o: Record) => new Headers(o); + expect(sameSite(h({ origin: "https://recall.example", host: "recall.example" }))).toBe(true); + expect(sameSite(h({ origin: "https://evil.example", host: "recall.example" }))).toBe(false); + expect(sameSite(h({ host: "recall.example" }))).toBe(false); // scripts often send no Origin + expect(sameSite(h({ origin: "not a url", host: "recall.example" }))).toBe(false); + }); + + test("the client is never taken from X-Forwarded-For, which the client can write", () => { + const spoofed = new Headers({ "x-forwarded-for": "1.2.3.4", "x-real-ip": "203.0.113.9" }); + expect(clientKey(spoofed, true)).toBe("203.0.113.9"); + expect(clientKey(spoofed, false)).toBe("local"); + expect(clientKey(new Headers({ "x-forwarded-for": "1.2.3.4" }), true)).toBe("local"); + }); + + test("the fallback limit counts per client per window", () => { + const limit = new WindowLimit(2, 60_000); + expect([limit.over("a", 0), limit.over("a", 1), limit.over("a", 2)]).toEqual([false, false, true]); + expect(limit.over("b", 3)).toBe(false); // someone else + expect(limit.over("a", 61_000)).toBe(false); // a minute later + }); + + test("the daily cap stops at the cap and starts again the next UTC day", () => { + const cap = new DailyCap(2); + const d1 = new Date("2026-11-01T10:00:00Z"); + expect([cap.reached(d1), cap.reached(d1), cap.reached(d1)]).toEqual([false, false, true]); + expect(cap.reached(new Date("2026-11-02T00:00:01Z"))).toBe(false); + expect(dailyCapFrom({ VOICE_DAILY_CAP: "50" })).toBe(50); + expect(dailyCapFrom({ VOICE_DAILY_CAP: "nonsense" })).toBe(300); + expect(dailyCapFrom({})).toBe(300); + }); +}); diff --git a/apps/web/e2e/site.spec.ts b/apps/web/e2e/site.spec.ts index d8afddc..2f68b13 100644 --- a/apps/web/e2e/site.spec.ts +++ b/apps/web/e2e/site.spec.ts @@ -90,20 +90,34 @@ test.describe("privacy note", () => { }); test.describe("voice API", () => { - test("rejects anything that isn't a WAV", async ({ request }) => { - const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "text/plain" }, data: "hello" }); + // a browser always sends Origin on a POST; the route answers only this site's own pages + const own = (baseURL?: string) => new URL(baseURL ?? "http://localhost:3000").origin; + + test("refuses calls that don't come from this site's pages", async ({ request, baseURL }) => { + const wav = { "content-type": "audio/wav" }; + for (const headers of [wav, { ...wav, origin: "https://elsewhere.example" }]) { + const res = await request.post("/api/voice/transcribe", { headers, data: Buffer.alloc(4000) }); + expect(res.status()).toBe(403); + expect(await res.json()).toEqual({ error: "forbidden" }); + } + const ok = await request.post("/api/voice/transcribe", { headers: { ...wav, origin: own(baseURL) }, data: Buffer.alloc(100) }); + expect(ok.status()).toBe(400); // past the check: too short, and no speech service was called + }); + + test("rejects anything that isn't a WAV", async ({ request, baseURL }) => { + const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "text/plain", origin: own(baseURL) }, data: "hello" }); expect(res.status()).toBe(415); expect(await res.json()).toEqual({ error: "wrong_format" }); }); - test("rejects an empty recording without calling the speech service", async ({ request }) => { - const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "audio/wav" }, data: Buffer.alloc(100) }); + test("rejects an empty recording without calling the speech service", async ({ request, baseURL }) => { + const res = await request.post("/api/voice/transcribe", { headers: { "content-type": "audio/wav", origin: own(baseURL) }, data: Buffer.alloc(100) }); expect(res.status()).toBe(400); expect(await res.json()).toEqual({ error: "too_short" }); }); test("every error code it can return has a message for visitors", () => { - for (const code of ["not_configured", "wrong_format", "rate_limited", "too_long", "too_short", "unreachable", "failed"]) { + for (const code of ["disabled", "not_configured", "forbidden", "wrong_format", "rate_limited", "daily_limit", "too_long", "too_short", "unreachable", "failed"]) { expect(en.ask.errors[code as keyof typeof en.ask.errors]).toBeTruthy(); } }); diff --git a/apps/web/i18n/dictionaries/en.ts b/apps/web/i18n/dictionaries/en.ts index bbcabb9..71b31b6 100644 --- a/apps/web/i18n/dictionaries/en.ts +++ b/apps/web/i18n/dictionaries/en.ts @@ -68,6 +68,9 @@ export const en = { errors: { microphone: "The microphone isn’t available. Check this site’s microphone permission.", not_configured: "Voice questions aren’t set up on this server yet.", + disabled: "Asking by voice is paused for now. Pick a question above instead.", + forbidden: "Voice questions only work from this page.", + daily_limit: "Today’s voice questions are used up. Pick a question above, or try again tomorrow.", wrong_format: "That recording couldn’t be sent. Try again.", rate_limited: "Too many questions at once. Try again in a minute.", too_long: "That recording is too long.", diff --git a/apps/web/lib/voice-guard.ts b/apps/web/lib/voice-guard.ts new file mode 100644 index 0000000..84040c5 --- /dev/null +++ b/apps/web/lib/voice-guard.ts @@ -0,0 +1,76 @@ +/** + * Guards for the voice demo route, the only route on the site that spends money (Muse Voice Transcribe on the Meta + * Model API). Kept apart from the route so each rule is tested on its own (e2e/lib.spec.ts). + * + * The hard stops are the Vercel firewall rule (`voice-transcribe`, checked with @vercel/firewall in production) and + * the off switch. The in-memory limits are a fallback: each serverless instance counts on its own, so across + * instances they are approximate. + */ + +/** VOICE_DEMO=off turns the voice demo off at once, without a deploy. */ +export function voiceDisabled(env: Record): boolean { + return (env.VOICE_DEMO ?? "").toLowerCase() === "off"; +} + +/** Only this site's own pages may call the route: a browser always sends Origin on a POST. */ +export function sameSite(headers: Headers): boolean { + const origin = headers.get("origin"); + const host = headers.get("x-forwarded-host") ?? headers.get("host"); + if (!origin || !host) return false; + try { + return new URL(origin).host === host; + } catch { + return false; + } +} + +/** + * Who is asking, for the fallback limit. On Vercel `x-real-ip` is the client's address as Vercel saw it (correct + * once no proxy sits in front); X-Forwarded-For is never used, because a client can write its first entry. + */ +export function clientKey(headers: Headers, onVercel: boolean): string { + return (onVercel && headers.get("x-real-ip")) || "local"; +} + +/** Requests per window per client, in this instance's memory. */ +export class WindowLimit { + private readonly hits = new Map(); + constructor( + private readonly limit: number, + private readonly windowMs: number, + ) {} + + /** Counts this request; true when it is over the limit. */ + over(key: string, now = Date.now()): boolean { + const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs); + recent.push(now); + this.hits.set(key, recent); + if (this.hits.size > 5000) this.hits.clear(); // keep memory bounded + return recent.length > this.limit; + } +} + +/** Requests per UTC day, for the whole instance: a ceiling on what a bot can spend. */ +export class DailyCap { + private day = ""; + private count = 0; + constructor(private readonly cap: number) {} + + /** Counts this request; true when today's cap is already used up. */ + reached(now = new Date()): boolean { + const day = now.toISOString().slice(0, 10); + if (day !== this.day) { + this.day = day; + this.count = 0; + } + if (this.count >= this.cap) return true; + this.count += 1; + return false; + } +} + +/** VOICE_DAILY_CAP, or 300 a day. */ +export function dailyCapFrom(env: Record): number { + const n = Number(env.VOICE_DAILY_CAP); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 300; +} diff --git a/apps/web/package.json b/apps/web/package.json index 04fb85a..646fa87 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@gsap/react": "^2.1.2", "@react-three/fiber": "9.8.2", + "@vercel/firewall": "1.2.6", "@vercel/speed-insights": "^2.0.0", "gsap": "^3.15.0", "motion": "^14.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de0f645..87e3df0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@react-three/fiber': specifier: 9.8.2 version: 9.8.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(three@0.186.1) + '@vercel/firewall': + specifier: 1.2.6 + version: 1.2.6 '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@16.4.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1) @@ -1041,6 +1044,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/firewall@1.2.6': + resolution: {integrity: sha512-89ybsa2rnVNdw6RI3jGOVvqUJaBj6Umm4IagC1o3PKtr/MXxBKOLek+yyUgF0csA0O5W+dzcpdIhKxVXhgVVtA==} + engines: {node: '>= 20'} + '@vercel/speed-insights@2.0.0': resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==} peerDependencies: @@ -3130,6 +3137,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vercel/firewall@1.2.6': {} + '@vercel/speed-insights@2.0.0(next@16.4.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1)': optionalDependencies: next: 16.4.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)