Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/actions/loom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);

Expand Down
17 changes: 17 additions & 0 deletions apps/web/actions/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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()
Expand Down
17 changes: 14 additions & 3 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -123,6 +129,7 @@ export async function POST(request: NextRequest) {
() =>
[] as {
ownerId: string;
orgId: string | null;
firstViewEmailSentAt: Date | null;
videoName: string;
createdAt: Date;
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/api/desktop/[...route]/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/api/settings/billing/guest-checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
8 changes: 8 additions & 0 deletions apps/web/app/api/tools/loom-download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");

Expand Down
76 changes: 76 additions & 0 deletions apps/web/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
Loading