From 7d97f6e7a0023dc8bd3cf440f590d56c196be07b Mon Sep 17 00:00:00 2001 From: lazyGPT07 Date: Fri, 12 Jun 2026 22:03:00 -0600 Subject: [PATCH 1/2] fix magic-link redirect validation --- src/app/auth/confirm/route.test.ts | 26 ++++++++++++++++++++++++++ src/app/auth/confirm/route.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/app/auth/confirm/route.test.ts diff --git a/src/app/auth/confirm/route.test.ts b/src/app/auth/confirm/route.test.ts new file mode 100644 index 00000000..b9a1aeff --- /dev/null +++ b/src/app/auth/confirm/route.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveMagicLinkRedirect } from "./route"; + +describe("resolveMagicLinkRedirect", () => { + const appUrl = "https://ugig.net"; + + it("preserves internal paths and query parameters", () => { + expect(resolveMagicLinkRedirect(appUrl, "/dashboard?tab=applications")).toBe( + "https://ugig.net/dashboard?tab=applications" + ); + }); + + it.each([ + ["host-like value", "@example.com"], + ["absolute URL", "https://example.com"], + ["protocol-relative URL", "//example.com"], + ["backslash-relative URL", "\\example.com"], + ["mixed slash URL", "/\\example.com"], + ])("falls back for %s", (_label, next) => { + expect(resolveMagicLinkRedirect(appUrl, next)).toBe("https://ugig.net/dashboard"); + }); + + it("falls back when next is missing", () => { + expect(resolveMagicLinkRedirect(appUrl, null)).toBe("https://ugig.net/dashboard"); + }); +}); diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts index e0497abf..57ed6fd7 100644 --- a/src/app/auth/confirm/route.ts +++ b/src/app/auth/confirm/route.ts @@ -14,10 +14,31 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +export function resolveMagicLinkRedirect(appUrl: string, next: string | null): string { + const fallback = new URL("/dashboard", appUrl); + + if (!next?.startsWith("/")) { + return fallback.toString(); + } + + try { + const target = new URL(next, fallback.origin); + return target.origin === fallback.origin ? target.toString() : fallback.toString(); + } catch { + return fallback.toString(); + } +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const tokenHash = searchParams.get("token_hash"); - const type = searchParams.get("type") as "signup" | "email" | "recovery" | "invite" | "magiclink" | null; + const type = searchParams.get("type") as + | "signup" + | "email" + | "recovery" + | "invite" + | "magiclink" + | null; const next = searchParams.get("next"); const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; @@ -48,8 +69,8 @@ export async function GET(request: NextRequest) { } // Magic link (OAuth flow) — go to next or dashboard - if (type === "magiclink" && next) { - return NextResponse.redirect(`${appUrl}${next}`); + if (type === "magiclink") { + return NextResponse.redirect(resolveMagicLinkRedirect(appUrl, next)); } // Signup/email confirmation — go to login with success message From 60410f021caf0bd1a85ce11a7f5e3ceceb6d8a3a Mon Sep 17 00:00:00 2001 From: lazyGPT07 Date: Sat, 13 Jun 2026 03:14:41 -0600 Subject: [PATCH 2/2] test(auth): cover empty magic-link redirects --- src/app/auth/confirm/route.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/auth/confirm/route.test.ts b/src/app/auth/confirm/route.test.ts index b9a1aeff..e13231d9 100644 --- a/src/app/auth/confirm/route.test.ts +++ b/src/app/auth/confirm/route.test.ts @@ -14,8 +14,9 @@ describe("resolveMagicLinkRedirect", () => { ["host-like value", "@example.com"], ["absolute URL", "https://example.com"], ["protocol-relative URL", "//example.com"], - ["backslash-relative URL", "\\example.com"], + ["leading backslash", "\\example.com"], ["mixed slash URL", "/\\example.com"], + ["empty string", ""], ])("falls back for %s", (_label, next) => { expect(resolveMagicLinkRedirect(appUrl, next)).toBe("https://ugig.net/dashboard"); });