diff --git a/apps/web/__tests__/unit/rate-limit-ids.test.ts b/apps/web/__tests__/unit/rate-limit-ids.test.ts new file mode 100644 index 00000000000..ba7060de273 --- /dev/null +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { RATE_LIMIT_IDS } from "@/lib/rate-limit"; + +const WEB_ROOT = join(__dirname, "..", ".."); +const DECLARATION_FILE = "lib/rate-limit.ts"; +const TEST_DIR = "__tests__/"; + +/** + * Ids that are intentionally declared ahead of being wired. Each entry needs a + * reason; removing one from this list and wiring the endpoint is the goal. + * Keeping it explicit means a NEW unwired id fails the test instead of quietly + * joining an already-failing count. + */ +const KNOWN_UNWIRED: Record = { + AUTH_OTP_VERIFY: "no OTP verification route located yet (see #2039)", + AUTH_OTP_SEND: "no OTP send route located yet (see #2039)", + MESSENGER_MESSAGE: "anonymous support chat route not located yet (see #2039)", + DESKTOP_LOGS: "desktop log forwarding route not located yet (see #2039)", +}; + +/** + * Every key referenced somewhere other than its own declaration. + * + * Uses git's own file list so the search sees exactly the tracked sources and + * never walks node_modules or .next. Memoized: the scan reads every tracked + * TS/TSX file, and each test in this suite needs the same answer. + */ +let referencedCache: Set | undefined; + +function referencedKeys(): Set { + if (referencedCache) return referencedCache; + + const tracked = execFileSync( + "git", + ["ls-files", "-z", "*.ts", "*.tsx"], + { cwd: WEB_ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, + ) + .split("\0") + .filter( + (file) => + file && + file !== DECLARATION_FILE && + // Only runtime call sites count. A test that merely names an id + // would otherwise make an unwired endpoint look protected — and + // this file itself references every key, which would make the + // guard vacuous. + !file.startsWith(TEST_DIR), + ); + + const found = new Set(); + const keys = Object.keys(RATE_LIMIT_IDS); + + for (const file of tracked) { + let source: string; + try { + source = readFileSync(join(WEB_ROOT, file), "utf8"); + } catch (error) { + // A tracked file missing from the worktree is expected (deleted but + // still in the index). Anything else - a permission problem, an I/O + // error - would silently shrink the scan and turn this guard into a + // no-op, so it has to surface. + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + + for (const key of keys) { + if (source.includes(`RATE_LIMIT_IDS.${key}`)) found.add(key); + } + } + + referencedCache = found; + return found; +} + +describe("RATE_LIMIT_IDS", () => { + it("has a unique rule id per key", () => { + const ids = Object.values(RATE_LIMIT_IDS); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every declared id is actually called somewhere", () => { + // A declared-but-uncalled id looks like protection in code review and in + // the Vercel Firewall dashboard while the endpoint runs unlimited. The + // helper already fails open when a dashboard rule is missing, so an + // unreferenced constant is a second, quieter way to have no limit. + const referenced = referencedKeys(); + const unused = Object.keys(RATE_LIMIT_IDS).filter( + (key) => !referenced.has(key) && !(key in KNOWN_UNWIRED), + ); + + expect(unused).toEqual([]); + }); + + it("does not carry a stale allowance for an id that is now wired", () => { + // Keeps the allow-list honest: once an endpoint is wired, its entry has + // to be deleted rather than lingering and masking a future regression. + const referenced = referencedKeys(); + const staleAllowances = Object.keys(KNOWN_UNWIRED).filter((key) => + referenced.has(key), + ); + + expect(staleAllowances).toEqual([]); + }); + + it("only allows ids that actually exist", () => { + const unknown = Object.keys(KNOWN_UNWIRED).filter( + (key) => !(key in RATE_LIMIT_IDS), + ); + + expect(unknown).toEqual([]); + }); +}); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..43d586b5c3b 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -12,6 +12,7 @@ import { createAnonymousViewNotification, sendFirstViewEmail, } from "@/lib/Notification"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; interface TrackPayload { @@ -42,6 +43,15 @@ const decodeUrlEncodedHeaderValue = (value?: string | null) => { }; export async function POST(request: NextRequest) { + // Unauthenticated by design (provideOptionalAuth), and every accepted call + // costs a Tinybird ingest and can fan out into view notifications and a + // first-view email. + if (await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK, { + headers: request.headers, + })) { + return Response.json({ error: "Too many requests" }, { status: 429 }); + } + let body: TrackPayload; try { body = (await request.json()) as TrackPayload; diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts index 71889ee4e1e..99e365dc33b 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -3,8 +3,17 @@ import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; import { PostHog } from "posthog-node"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; export async function POST(request: NextRequest) { + // Unauthenticated: every accepted call creates a real Stripe checkout + // session, so this is rate limited before anything is read from the body. + if (await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, { + headers: request.headers, + })) { + return Response.json({ error: "Too many requests" }, { status: 429 }); + } + console.log("Starting guest checkout process"); const { priceId, quantity, platform } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web"; diff --git a/apps/web/app/api/tools/loom-download/route.ts b/apps/web/app/api/tools/loom-download/route.ts index 193a4057387..c7d98058dd7 100644 --- a/apps/web/app/api/tools/loom-download/route.ts +++ b/apps/web/app/api/tools/loom-download/route.ts @@ -5,6 +5,7 @@ import { isMediaServerConfigured, } from "@/lib/media-client"; import { convertRemoteVideoToMp4Buffer } from "@/lib/video-convert"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; function isHlsUrl(url: string): boolean { return (url.split("?")[0] ?? "").toLowerCase().endsWith(".m3u8"); @@ -160,6 +161,14 @@ async function tryMp4CandidateDownload( } export async function GET(request: NextRequest) { + // Unauthenticated, and an accepted call downloads a remote video and can run + // it through ffmpeg, so it is bounded before any fetching starts. + if (await isRateLimited(RATE_LIMIT_IDS.LOOM_DOWNLOAD, { + headers: request.headers, + })) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + const videoId = request.nextUrl.searchParams.get("id"); const videoName = request.nextUrl.searchParams.get("name");