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..bd40a61ff63 --- /dev/null +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RATE_LIMIT_IDS } from "../../lib/rate-limit"; + +// Rate limit IDs declared in advance for firewall rules or separate app packages +// that are intentionally not yet wired in apps/web endpoints. +const UNWIRED_RATE_LIMIT_IDS = new Set([ + "AUTH_OTP_VERIFY", + "AUTH_OTP_SEND", + "LOOM_DOWNLOAD", + "MESSENGER_MESSAGE", + "DESKTOP_LOGS", +]); + +function getAllTsFiles(dir: string): string[] { + let results: string[] = []; + const list = readdirSync(dir); + for (const file of list) { + const filePath = join(dir, file); + const stat = statSync(filePath); + if (stat && stat.isDirectory()) { + if (file !== "node_modules" && file !== ".next" && file !== "dist") { + results = results.concat(getAllTsFiles(filePath)); + } + } else if (file.endsWith(".ts") || file.endsWith(".tsx")) { + if (!filePath.endsWith("lib/rate-limit.ts")) { + results.push(filePath); + } + } + } + return results; +} + +describe("RATE_LIMIT_IDS reference contract", () => { + it("ensures every active declared RATE_LIMIT_ID is referenced outside lib/rate-limit.ts", () => { + const webAppDir = join(process.cwd()); + const tsFiles = getAllTsFiles(webAppDir); + + let combinedSource = ""; + for (const file of tsFiles) { + combinedSource += readFileSync(file, "utf8") + "\n"; + } + + const unreferencedKeys: string[] = []; + + for (const [key, value] of Object.entries(RATE_LIMIT_IDS)) { + if (UNWIRED_RATE_LIMIT_IDS.has(key)) { + continue; + } + + const hasKeyRef = combinedSource.includes(`RATE_LIMIT_IDS.${key}`); + const hasValueRef = combinedSource.includes(`"${value}"`) || combinedSource.includes(`'${value}'`); + + if (!hasKeyRef && !hasValueRef) { + unreferencedKeys.push(key); + } + } + + expect( + unreferencedKeys, + `The following RATE_LIMIT_IDS are declared but never referenced: ${unreferencedKeys.join(", ")}`, + ).toEqual([]); + }); +}); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..7ba46994fe4 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,17 @@ const decodeUrlEncodedHeaderValue = (value?: string | null) => { }; export async function POST(request: NextRequest) { + if ( + await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK, { + headers: request.headers, + }) + ) { + return Response.json( + { error: "Too many tracking requests. Please try again later." }, + { 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 6726ae711c1..663a3e41c96 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -2,9 +2,22 @@ import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; + +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { + if ( + await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, { + headers: request.headers, + }) + ) { + return Response.json( + { error: "Too many checkout attempts. Please try again later." }, + { status: 429 }, + ); + } + console.log("Starting guest checkout process"); const { priceId, quantity, platform } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web";