diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 6227f91..1637a01 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -2,6 +2,7 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; +import { safeNext } from "@/lib/safe-next"; import { isSupabaseConfigured } from "@/lib/supabase/config"; // Always redirect to the canonical domain after OAuth so that arriving via @@ -13,7 +14,7 @@ const SITE_URL = export async function GET(request: Request) { const { searchParams } = new URL(request.url); const code = searchParams.get("code"); - const next = searchParams.get("next") ?? "/"; + const next = safeNext(searchParams.get("next")); // No Supabase configured (self-host / preview mode): nothing to exchange. if (!isSupabaseConfigured()) { diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 694d164..e91df90 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { createClient } from "@/lib/supabase/client"; +import { safeNext } from "@/lib/safe-next"; import { site } from "@/lib/site"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,7 +16,7 @@ import { Label } from "@/components/ui/label"; export function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); - const next = searchParams.get("next") ?? "/"; + const next = safeNext(searchParams.get("next")); const supabase = createClient(); diff --git a/src/lib/safe-next.test.ts b/src/lib/safe-next.test.ts new file mode 100644 index 0000000..8d5b853 --- /dev/null +++ b/src/lib/safe-next.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { safeNext } from "./safe-next"; + +describe("safeNext", () => { + it.each([ + [null, "/"], + ["", "/"], + ["/", "/"], + ["/map", "/map"], + ["/calendar?term=fall#today", "/calendar?term=fall#today"], + ])("maps %j to %j", (raw, expected) => { + expect(safeNext(raw)).toBe(expected); + }); + + it.each([ + "https://evil.example", + "http://evil.example", + "//evil.example", + "///evil.example", + "/\\evil.example", + "/\tevil.example", + "/\nevil.example", + "\u0000/map", + "map", + "?next=/map", + "#map", + ])("rejects unsafe redirect %j", (raw) => { + expect(safeNext(raw)).toBe("/"); + }); +}); diff --git a/src/lib/safe-next.ts b/src/lib/safe-next.ts new file mode 100644 index 0000000..2f570d5 --- /dev/null +++ b/src/lib/safe-next.ts @@ -0,0 +1,16 @@ +const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/; + +export function safeNext(raw: string | null): string { + if (!raw) return "/"; + + if ( + !raw.startsWith("/") || + raw.startsWith("//") || + raw.includes("\\") || + CONTROL_CHAR_RE.test(raw) + ) { + return "/"; + } + + return raw; +}