From e4cb4003851be4b9aee8d7973a7246ecd3648a47 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:18:58 +0100 Subject: [PATCH 01/77] fix(web): add getSafeNextPath helper for auth redirects --- apps/web/__tests__/unit/safe-next.test.ts | 44 +++++++++++++++++++++++ apps/web/app/(org)/safe-next.ts | 25 +++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 apps/web/__tests__/unit/safe-next.test.ts create mode 100644 apps/web/app/(org)/safe-next.ts diff --git a/apps/web/__tests__/unit/safe-next.test.ts b/apps/web/__tests__/unit/safe-next.test.ts new file mode 100644 index 0000000000..5cd43c5364 --- /dev/null +++ b/apps/web/__tests__/unit/safe-next.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { getSafeNextPath } from "@/app/(org)/safe-next"; + +const ORIGIN = "https://cap.so"; + +describe("getSafeNextPath", () => { + it("returns the default for missing values", () => { + expect(getSafeNextPath(undefined, ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath(null, ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("", ORIGIN)).toBe("/dashboard"); + }); + + it("keeps same-origin paths with query and hash", () => { + expect(getSafeNextPath("/dashboard/caps?foo=1#bar", ORIGIN)).toBe( + "/dashboard/caps?foo=1#bar", + ); + expect(getSafeNextPath("https://cap.so/settings", ORIGIN)).toBe( + "/settings", + ); + }); + + it("uses the first value when next is repeated", () => { + expect(getSafeNextPath(["/a", "/b"], ORIGIN)).toBe("/a"); + }); + + it("rejects cross-origin and protocol-relative URLs", () => { + expect(getSafeNextPath("https://evil.com/x", ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("//evil.com/x", ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("javascript:alert(1)", ORIGIN)).toBe("/dashboard"); + }); + + it("rejects paths that normalize to protocol-relative URLs", () => { + expect(getSafeNextPath("/.//evil.com", ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("/..//evil.com", ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("/%2e//evil.com", ORIGIN)).toBe("/dashboard"); + expect(getSafeNextPath("/./%2e//evil.com/path?x=1", ORIGIN)).toBe( + "/dashboard", + ); + expect(getSafeNextPath("https://cap.so//evil.com", ORIGIN)).toBe( + "/dashboard", + ); + expect(getSafeNextPath("/\\evil.com", ORIGIN)).toBe("/dashboard"); + }); +}); diff --git a/apps/web/app/(org)/safe-next.ts b/apps/web/app/(org)/safe-next.ts new file mode 100644 index 0000000000..2e5e001b94 --- /dev/null +++ b/apps/web/app/(org)/safe-next.ts @@ -0,0 +1,25 @@ +const DEFAULT_AUTH_REDIRECT = "/dashboard"; + +export const getSafeNextPath = ( + next: string | string[] | null | undefined, + origin: string, +) => { + const value = Array.isArray(next) ? next[0] : next; + if (!value) return DEFAULT_AUTH_REDIRECT; + + try { + const base = new URL(origin); + const url = new URL(value, base); + if (url.origin !== base.origin) return DEFAULT_AUTH_REDIRECT; + const path = `${url.pathname}${url.search}${url.hash}`; + // Path normalization can turn inputs like /.//evil.com or + // https:////evil.com into //evil.com, which browsers treat as a + // protocol-relative URL when emitted in a Location header. + if (!path.startsWith("/") || path.startsWith("//") || path[1] === "\\") { + return DEFAULT_AUTH_REDIRECT; + } + return path; + } catch { + return DEFAULT_AUTH_REDIRECT; + } +}; From d32dd8944e88adb731593f5c64c1456f94ab2090 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:18:59 +0100 Subject: [PATCH 02/77] fix(web): validate next redirect in login flow --- apps/web/app/(org)/login/form.tsx | 11 ++++++++--- apps/web/app/(org)/login/page.tsx | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 0ca73f7d6b..c9a491527b 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -21,6 +21,7 @@ import { getOrganizationSSOData } from "@/actions/organization/get-organization- import { trackEvent } from "@/app/utils/analytics"; import { usePublicEnv } from "@/utils/public-env"; import { getEmailCodeCooldownSeconds, requestEmailCode } from "../auth-email"; +import { getSafeNextPath } from "../safe-next"; const MotionInput = motion(Input); const MotionLogoBadge = motion(LogoBadge); @@ -42,6 +43,8 @@ export function LoginForm() { null, ); const theme = Cookies.get("theme") || "light"; + const getNextPath = () => + next ? getSafeNextPath(next, window.location.origin) : null; useEffect(() => { document.body.className = theme === "dark" ? "dark" : "light"; @@ -103,13 +106,14 @@ export function LoginForm() { }, [emailSent]); const handleGoogleSignIn = () => { + const nextPath = getNextPath(); trackEvent("auth_started", { method: "google", is_signup: false, auth_surface: "login", }); signIn("google", { - ...(next && next.length > 0 ? { callbackUrl: next } : {}), + ...(nextPath ? { callbackUrl: nextPath } : {}), }); }; @@ -261,9 +265,10 @@ export function LoginForm() { setLoading(true); try { + const nextPath = getNextPath(); const normalizedEmail = await requestEmailCode({ email, - next, + next: nextPath, isSignup: false, authSurface: "login", }); @@ -274,7 +279,7 @@ export function LoginForm() { setLastEmailSentTime(sentAt); const params = new URLSearchParams({ email: normalizedEmail, - ...(next && { next }), + ...(nextPath && { next: nextPath }), lastSent: sentAt.toString(), }); router.push(`/verify-otp?${params.toString()}`); diff --git a/apps/web/app/(org)/login/page.tsx b/apps/web/app/(org)/login/page.tsx index 596de8b442..195cd182af 100644 --- a/apps/web/app/(org)/login/page.tsx +++ b/apps/web/app/(org)/login/page.tsx @@ -1,17 +1,26 @@ import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { getSafeNextPath } from "../safe-next"; import { LoginForm } from "./form"; export const dynamic = "force-dynamic"; -export default async function LoginPage() { - const session = await getCurrentUser(); +export default async function LoginPage(props: { + searchParams: Promise<{ next?: string | string[] }>; +}) { + const [searchParams, session] = await Promise.all([ + props.searchParams, + getCurrentUser(), + ]); + if (session) { - redirect("/dashboard"); + redirect(getSafeNextPath(searchParams.next, serverEnv().WEB_URL)); } + return (
From 11ad719bf1043781ad4def1e31126be840c67c5e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:18:59 +0100 Subject: [PATCH 03/77] fix(web): validate next redirect in signup form --- apps/web/app/(org)/signup/form.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(org)/signup/form.tsx b/apps/web/app/(org)/signup/form.tsx index 1e79c94326..c64f243096 100644 --- a/apps/web/app/(org)/signup/form.tsx +++ b/apps/web/app/(org)/signup/form.tsx @@ -21,6 +21,7 @@ import { getOrganizationSSOData } from "@/actions/organization/get-organization- import { trackEvent } from "@/app/utils/analytics"; import { usePublicEnv } from "@/utils/public-env"; import { getEmailCodeCooldownSeconds, requestEmailCode } from "../auth-email"; +import { getSafeNextPath } from "../safe-next"; const MotionInput = motion(Input); const MotionLogoBadge = motion(LogoBadge); @@ -42,6 +43,8 @@ export function SignupForm() { null, ); const theme = Cookies.get("theme") || "light"; + const getNextPath = () => + next ? getSafeNextPath(next, window.location.origin) : null; useEffect(() => { document.body.className = theme === "dark" ? "dark" : "light"; @@ -103,13 +106,14 @@ export function SignupForm() { }, [emailSent]); const handleGoogleSignIn = () => { + const nextPath = getNextPath(); trackEvent("auth_started", { method: "google", is_signup: true, auth_surface: "signup", }); signIn("google", { - ...(next && next.length > 0 ? { callbackUrl: next } : {}), + ...(nextPath ? { callbackUrl: nextPath } : {}), }); }; @@ -261,9 +265,10 @@ export function SignupForm() { setLoading(true); try { + const nextPath = getNextPath(); const normalizedEmail = await requestEmailCode({ email, - next, + next: nextPath, isSignup: true, authSurface: "signup", }); @@ -274,7 +279,7 @@ export function SignupForm() { setLastEmailSentTime(sentAt); const params = new URLSearchParams({ email: normalizedEmail, - ...(next && { next }), + ...(nextPath && { next: nextPath }), lastSent: sentAt.toString(), }); router.push(`/verify-otp?${params.toString()}`); From 3838769d3e0f977283435b1d5f70d35e7a4c62d4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:18:59 +0100 Subject: [PATCH 04/77] fix(web): validate next redirect in verify-otp flow --- apps/web/app/(org)/verify-otp/form.tsx | 9 +++++++-- apps/web/app/(org)/verify-otp/page.tsx | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index d152273c0f..ca8a88d9b6 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { signIn } from "next-auth/react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { getSafeNextPath } from "../safe-next"; export function VerifyOTPForm({ email, @@ -71,14 +72,17 @@ export function VerifyOTPForm({ }; const normalizedEmail = email.toLowerCase(); + const getNextPath = () => + next ? getSafeNextPath(next, window.location.origin) : "/dashboard"; const handleVerify = useMutation({ mutationFn: async (pastedCode?: string) => { const otpCode = pastedCode ?? code.join(""); if (otpCode.length !== 6) throw "Please enter a complete 6-digit code"; + const nextPath = getNextPath(); await fetch( - `/api/auth/callback/email?email=${encodeURIComponent(normalizedEmail)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent(next || "/dashboard")}`, + `/api/auth/callback/email?email=${encodeURIComponent(normalizedEmail)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent(nextPath)}`, ); const sessionRes = await fetch("/api/auth/session"); @@ -90,8 +94,9 @@ export function VerifyOTPForm({ } }, onSuccess: async () => { + const nextPath = getNextPath(); router.refresh(); - router.replace(next || "/dashboard"); + router.replace(nextPath); }, onError: (e) => { if (typeof e === "string") { diff --git a/apps/web/app/(org)/verify-otp/page.tsx b/apps/web/app/(org)/verify-otp/page.tsx index bf2c91e901..37abfc4745 100644 --- a/apps/web/app/(org)/verify-otp/page.tsx +++ b/apps/web/app/(org)/verify-otp/page.tsx @@ -1,6 +1,8 @@ import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; import { redirect } from "next/navigation"; import { Suspense } from "react"; +import { getSafeNextPath } from "../safe-next"; import { VerifyOTPForm } from "./form"; export const metadata = { @@ -14,7 +16,7 @@ export default async function VerifyOTPPage(props: { const user = await getCurrentUser(); if (user) { - redirect(searchParams.next || "/dashboard"); + redirect(getSafeNextPath(searchParams.next, serverEnv().WEB_URL)); } if (!searchParams.email) { From 471bce8f82758f4cae8c15e711da1a7c8d1dcc54 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:18:59 +0100 Subject: [PATCH 05/77] feat(web-domain): add FREE_PLAN_MAX_RECORDING_SECONDS constant --- packages/web-domain/src/Video.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index b04748c820..c4a9949bc3 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -13,6 +13,8 @@ import { UserId } from "./User.ts"; export const VideoId = Schema.String.pipe(Schema.brand("VideoId")); export type VideoId = typeof VideoId.Type; +export const FREE_PLAN_MAX_RECORDING_SECONDS = 5 * 60; + // Purposefully doesn't include password as this is a public class export class Video extends Schema.Class