diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 1627bd5ddb0..d8837bcce00 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -32,6 +32,7 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { start } from "workflow/api"; import { requireOrganizationAccess } from "@/actions/organization/authorization"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; @@ -257,6 +258,13 @@ export async function downloadLoomVideo( }; } + if (await isRateLimited(RATE_LIMIT_IDS.LOOM_DOWNLOAD)) { + return { + success: false, + error: "Too many requests. Please wait a moment, then try again.", + }; + } + try { const downloadUrl = await getLoomDownloadUrl(videoId); diff --git a/apps/web/actions/messenger.ts b/apps/web/actions/messenger.ts index fff2f9170f7..a37a0f9a0b6 100644 --- a/apps/web/actions/messenger.ts +++ b/apps/web/actions/messenger.ts @@ -25,6 +25,7 @@ import { storeConversationInSupermemory, syncCapKnowledgeBase, } from "@/lib/messenger/supermemory"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; const normalizeContent = (content: string) => content.trim().slice(0, 6000); @@ -174,6 +175,22 @@ export const sendMessengerUserMessage = async ({ throw new Error("Unauthorized"); } + // Rate-limit BEFORE any DB writes so a limited request can't persist a + // message row or advance the conversation timestamp (DB spam) — not just + // skip the expensive agent reply. + const rateLimitSubject = + viewer.user?.id ?? + conversation.userId ?? + activeAnonymousId ?? + conversation.anonymousId; + if ( + await isRateLimited(RATE_LIMIT_IDS.MESSENGER_MESSAGE, { + ...(rateLimitSubject ? { key: `messenger:${rateLimitSubject}` } : {}), + }) + ) { + throw new Error("Too many messages. Please wait a moment, then try again."); + } + const now = new Date(); await db() diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..2fcc5d13e89 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 { @@ -53,6 +54,10 @@ export async function POST(request: NextRequest) { return Response.json({ error: "videoId is required" }, { status: 400 }); } + if (await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK)) { + return Response.json({ error: "Too many requests" }, { status: 429 }); + } + const parsedSessionId = typeof body.sessionId === "string" ? body.sessionId.trim().slice(0, 128) || null @@ -108,6 +113,7 @@ export async function POST(request: NextRequest) { db() .select({ ownerId: videos.ownerId, + orgId: videos.orgId, firstViewEmailSentAt: videos.firstViewEmailSentAt, videoName: videos.name, createdAt: videos.createdAt, @@ -123,6 +129,7 @@ export async function POST(request: NextRequest) { () => [] as { ownerId: string; + orgId: string | null; firstViewEmailSentAt: Date | null; videoName: string; createdAt: Date; @@ -144,10 +151,14 @@ export async function POST(request: NextRequest) { return; } + // Derive the tenant strictly from the looked-up video record so a + // caller cannot spoof another tenant's analytics via body.orgId / + // body.ownerId. Prefer the video's org id (what every analytics reader + // filters tenant_id by), then fall back to per-owner scoping, and only + // to host/public when the video is unknown. const tenantId = - body.orgId || - videoRecord?.ownerId || - body.ownerId || + videoRecord?.orgId ?? + videoRecord?.ownerId ?? (hostname ? `domain:${hostname}` : "public"); const tinybird = yield* Tinybird; diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 694119771b9..1a963787383 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -20,6 +20,7 @@ import { type Context, Hono } from "hono"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; import { z } from "zod"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; import { withAuth, withOptionalAuth } from "../../utils"; import { @@ -335,6 +336,14 @@ app.post( ), withOptionalAuth, async (c) => { + if ( + await isRateLimited(RATE_LIMIT_IDS.DESKTOP_LOGS, { + headers: c.req.raw.headers, + }) + ) { + return c.json({ error: "Too many requests" }, { status: 429 }); + } + const { log, os, 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 a8e5dc63f30..12aeaaccc5c 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,18 @@ import { buildEnv, serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; import { PostHog } from "posthog-node"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; export async function POST(request: NextRequest) { console.log("Starting guest checkout process"); + + if (await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT)) { + return Response.json( + { error: "Too many requests. Please try again later." }, + { status: 429 }, + ); + } + const { priceId, quantity } = await request.json(); console.log("Received guest checkout request:", { priceId, quantity }); diff --git a/apps/web/app/api/tools/loom-download/route.ts b/apps/web/app/api/tools/loom-download/route.ts index 44293cf52f3..ecf16911bcb 100644 --- a/apps/web/app/api/tools/loom-download/route.ts +++ b/apps/web/app/api/tools/loom-download/route.ts @@ -4,6 +4,7 @@ import { fetchConvertedVideoViaMediaServer, isMediaServerConfigured, } from "@/lib/media-client"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { convertRemoteVideoToMp4Buffer } from "@/lib/video-convert"; function isHlsUrl(url: string): boolean { @@ -160,6 +161,13 @@ async function tryMp4CandidateDownload( } export async function GET(request: NextRequest) { + if (await isRateLimited(RATE_LIMIT_IDS.LOOM_DOWNLOAD)) { + return NextResponse.json( + { error: "Too many requests. Please try again in a moment." }, + { status: 429 }, + ); + } + const videoId = request.nextUrl.searchParams.get("id"); const videoName = request.nextUrl.searchParams.get("name"); diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 00000000000..168a800f168 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,76 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { headers as nextHeaders } from "next/headers"; + +/** + * Best-effort per-key rate limiting backed by the Vercel Firewall. + * + * IMPORTANT: each `ruleId` passed here must also be configured as a Rate Limit + * rule in the Vercel Firewall dashboard (Firewall → Rate Limiting) with a + * `@vercel/firewall` rule condition whose ID matches `ruleId`, plus the desired + * window / limit / action. An ID that has no matching dashboard rule fails + * OPEN (`checkRateLimit` returns `{ rateLimited: false, error: "not-found" }`), + * so this helper never breaks self-hosted deploys that lack the firewall — but + * it also provides no protection until the rule exists. + * + * Mirrors the existing pattern in `actions/collections/password.ts` and + * `actions/send-download-link.ts`: + * - only enforced in production, + * - best-effort (any error → not limited) so a firewall/IP-header outage can + * never take down the underlying feature. + * + * @param ruleId Stable rule id, also configured in the Vercel Firewall. + * @param opts.key Optional bucket key (e.g. per-email / per-user). Defaults to + * the caller IP (the firewall's default behaviour). + * @param opts.headers Optional request headers (required inside Hono handlers + * where `next/headers` is unavailable; defaults to the App + * Router request headers). + * @returns `true` when the request should be rejected. + */ +export async function isRateLimited( + ruleId: string, + opts?: { key?: string; headers?: Headers }, +): Promise { + if (process.env.NODE_ENV !== "production") return false; + + try { + const headersList = opts?.headers ?? (await nextHeaders()); + const request = new Request("https://cap.so/api/rate-limit", { + method: "POST", + headers: headersList, + }); + + const { rateLimited } = await checkRateLimit(ruleId, { + request, + ...(opts?.key ? { rateLimitKey: opts.key } : {}), + }); + + return rateLimited; + } catch (error) { + console.warn(`Rate limit check failed for "${ruleId}":`, error); + return false; + } +} + +/** + * Canonical Vercel Firewall rate-limit rule ids introduced by the security + * hardening pass. Each MUST be created in the Vercel Firewall dashboard for the + * corresponding protection to take effect (see `isRateLimited`). + */ +export const RATE_LIMIT_IDS = { + /** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */ + AUTH_OTP_VERIFY: "rl_auth_otp_verify", + /** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */ + AUTH_OTP_SEND: "rl_auth_otp_send", + /** Unauthed Loom download/convert (ffmpeg + memory DoS). Suggested: 10 / 1m per IP. */ + LOOM_DOWNLOAD: "rl_loom_download", + /** Unauthed transcript translation (Groq cost). Suggested: 10 / 1m per IP. */ + TRANSLATE_TRANSCRIPT: "rl_translate_transcript", + /** Anonymous support-chat messages (Groq + Supermemory cost). Suggested: 20 / 1m per IP. */ + MESSENGER_MESSAGE: "rl_messenger_message", + /** Unauthed analytics view tracking (Tinybird ingest + notifications). Suggested: 60 / 1m per IP. */ + ANALYTICS_TRACK: "rl_analytics_track", + /** Unauthed guest checkout (Stripe object/cost abuse). Suggested: 10 / 10m per IP. */ + GUEST_CHECKOUT: "rl_guest_checkout", + /** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */ + DESKTOP_LOGS: "rl_desktop_logs", +} as const;