diff --git a/apps/web/src/__tests__/safe-redirect.test.ts b/apps/web/src/__tests__/safe-redirect.test.ts new file mode 100644 index 0000000..d20a8be --- /dev/null +++ b/apps/web/src/__tests__/safe-redirect.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { safeRedirectPath } from "@/lib/safe-redirect"; + +describe("safeRedirectPath", () => { + it("preserves internal paths, queries, and fragments", () => { + expect(safeRedirectPath("/usage?range=30d#details")).toBe( + "/usage?range=30d#details" + ); + }); + + it.each([ + "https://attacker.example/phish", + "//attacker.example/phish", + "/\\attacker.example/phish", + "javascript:alert(1)", + "account", + ])("rejects unsafe redirect %s", (value) => { + expect(safeRedirectPath(value)).toBe("/account"); + }); + + it("uses the requested fallback for missing values", () => { + expect(safeRedirectPath(null, "/")).toBe("/"); + }); +}); diff --git a/apps/web/src/app/api/auth/callback/route.ts b/apps/web/src/app/api/auth/callback/route.ts index 24ffcf6..cc1dbba 100644 --- a/apps/web/src/app/api/auth/callback/route.ts +++ b/apps/web/src/app/api/auth/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; +import { safeRedirectPath } from "@/lib/safe-redirect"; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -26,8 +27,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const code = searchParams.get("code"); const ref = searchParams.get("ref") || ""; - const nextParam = searchParams.get("next") || "/account"; - const nextPath = nextParam.startsWith("/") ? nextParam : "/account"; + const nextPath = safeRedirectPath(searchParams.get("next")); if (!code) { return NextResponse.redirect(`${APP_URL}/auth/login?error=no_code`); diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx index f878fd8..1b656a0 100644 --- a/apps/web/src/app/auth/login/page.tsx +++ b/apps/web/src/app/auth/login/page.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { setAccessToken } from "@/lib/auth-client"; +import { safeRedirectPath } from "@/lib/safe-redirect"; export default function LoginPage() { const [email, setEmail] = useState(""); @@ -11,8 +12,7 @@ export default function LoginPage() { const [error, setError] = useState(""); const nextPath = useMemo(() => { if (typeof window === "undefined") return "/account"; - const raw = new URLSearchParams(window.location.search).get("next") || "/account"; - return raw.startsWith("/") ? raw : "/account"; + return safeRedirectPath(new URLSearchParams(window.location.search).get("next")); }, []); const handleSubmit = async (e: React.FormEvent) => { diff --git a/apps/web/src/app/auth/signup/page.tsx b/apps/web/src/app/auth/signup/page.tsx index 13877cc..3a9305e 100644 --- a/apps/web/src/app/auth/signup/page.tsx +++ b/apps/web/src/app/auth/signup/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import Link from "next/link"; +import { safeRedirectPath } from "@/lib/safe-redirect"; export default function SignupPage() { const [email, setEmail] = useState(""); @@ -14,8 +15,7 @@ export default function SignupPage() { const [success, setSuccess] = useState(false); const nextPath = useMemo(() => { if (typeof window === "undefined") return "/account"; - const raw = new URLSearchParams(window.location.search).get("next") || "/account"; - return raw.startsWith("/") ? raw : "/account"; + return safeRedirectPath(new URLSearchParams(window.location.search).get("next")); }, []); useEffect(() => { diff --git a/apps/web/src/lib/safe-redirect.ts b/apps/web/src/lib/safe-redirect.ts new file mode 100644 index 0000000..4efc1c3 --- /dev/null +++ b/apps/web/src/lib/safe-redirect.ts @@ -0,0 +1,16 @@ +const REDIRECT_BASE = "https://threatcrush.invalid"; + +export function safeRedirectPath( + value: string | null | undefined, + fallback = "/account" +): string { + if (!value || !value.startsWith("/")) return fallback; + + try { + const url = new URL(value, REDIRECT_BASE); + if (url.origin !== REDIRECT_BASE) return fallback; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return fallback; + } +}