From 07a82ace651b9da40c0655ae4d8372ee461c3fba Mon Sep 17 00:00:00 2001 From: Samarth Nimangre Date: Tue, 4 Aug 2026 00:41:08 +0000 Subject: [PATCH 1/2] fix(security): wire RATE_LIMIT_IDS to guest-checkout and analytics/track endpoints and add contract test (#2039) --- .../web/__tests__/unit/rate-limit-ids.test.ts | 51 +++++++++++++++++++ apps/web/app/api/analytics/track/route.ts | 12 +++++ .../settings/billing/guest-checkout/route.ts | 13 +++++ 3 files changed, 76 insertions(+) create mode 100644 apps/web/__tests__/unit/rate-limit-ids.test.ts 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..15923ccfc83 --- /dev/null +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -0,0 +1,51 @@ +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"; + +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 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)) { + 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"; From a9af510861ca212e4dd9447ce402a5434c7cbe45 Mon Sep 17 00:00:00 2001 From: Samarth Nimangre Date: Tue, 4 Aug 2026 01:06:43 +0000 Subject: [PATCH 2/2] fix(rendering): handle 0-byte audio segment files gracefully in Audio::new and SegmentRecordings (#2069) --- crates/rendering/src/project_recordings.rs | 30 ++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index e0b9985b3e1..ed43cd50c62 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -91,6 +91,12 @@ pub struct Audio { impl Audio { pub fn new(path: impl AsRef, start_time: f64) -> Result { fn inner(path: &Path, start_time: f64) -> Result { + if let Ok(metadata) = std::fs::metadata(path) { + if metadata.len() == 0 { + return Err("Audio file is 0 bytes (empty)".to_string()); + } + } + let input = ffmpeg::format::input(path).map_err(|e| format!("Failed to open audio: {e}"))?; let stream = input @@ -131,12 +137,18 @@ impl ProjectRecordingsMeta { Video::new(camera.path.to_path(recording_path), 0.0) .expect("Failed to read camera video") }); - let mic = s + let mic = match s .audio .as_ref() .map(|audio| Audio::new(audio.path.to_path(recording_path), 0.0)) .transpose() - .expect("Failed to read audio"); + { + Ok(audio) => audio, + Err(e) => { + tracing::warn!("Failed to load audio for single segment, treating as no audio: {e}"); + None + } + }; vec![SegmentRecordings { display, @@ -182,6 +194,16 @@ impl ProjectRecordingsMeta { }) }; + let mic = match Option::map(s.mic.as_ref(), load_audio).transpose() { + Ok(audio) => audio, + Err(e) => { + tracing::warn!( + "Failed to load mic audio for segment, treating as no audio: {e}" + ); + None + } + }; + let system_audio = match Option::map(s.system_audio.as_ref(), load_audio) .transpose() { @@ -199,9 +221,7 @@ impl ProjectRecordingsMeta { camera: Option::map(s.camera.as_ref(), load_video) .transpose() .map_err(|e| format!("camera / {e}"))?, - mic: Option::map(s.mic.as_ref(), load_audio) - .transpose() - .map_err(|e| format!("mic / {e}"))?, + mic, system_audio, }) })