From 52ce37e216d9e317b109606d1eacf0683f9aeb3d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 01:11:44 +0200 Subject: [PATCH] fix: Weiterleitungen innerhalb der Seite relativ statt auf die Produktionsdomain Sechs Route-Handler (Hinweis-Weiterleitung, E-Mail-Bestaetigung, Newsletter bestaetigen/abmelden, Entra-Login und -Callback) bauten ihr Weiterleitungsziel mit getBaseUrl() absolut zusammen. Ohne gesetztes NEXT_PUBLIC_SITE_URL (lokal, Staging, Vorschau-Host) sprang man damit auf https://wabenhain.com, und die gerade gesetzte Flash-Meldung ging auf dem fremden Host verloren - aufgefallen, als die Admin-Pruefung nach /api/hinweis umleitete. Neuer Helfer lib/redirect.ts (redirectWithinSite) mit relativem Location-Header, analog zu redirect() in Server Actions. Er lehnt alles ausser Pfaden dieser Seite ab ("//host", "/\host", absolute URLs), damit daraus keine offene Weiterleitung werden kann. getSiteUrl() bleibt fuer Adressen, die die Seite verlassen (E-Mails, Stripe, Entra-redirect_uri, Sitemap, Canonicals). proxy.ts bleibt bei new URL("/konto", request.url) (eigener Host): der Proxy verlangt einen absoluten Location-Header, ein relativer endet dort in "Invalid URL" - als Kommentar festgehalten. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/api/auth/entra/callback/route.ts | 9 +++---- src/app/api/auth/entra/login/route.ts | 4 +-- src/app/api/hinweis/route.ts | 8 +++--- src/app/api/konto/email-bestaetigen/route.ts | 8 +++--- src/app/api/newsletter/confirm/route.ts | 10 +++---- src/app/api/newsletter/unsubscribe/route.ts | 10 +++---- src/lib/redirect.test.ts | 28 ++++++++++++++++++++ src/lib/redirect.ts | 25 +++++++++++++++++ src/proxy.ts | 3 +++ 9 files changed, 76 insertions(+), 29 deletions(-) create mode 100644 src/lib/redirect.test.ts create mode 100644 src/lib/redirect.ts diff --git a/src/app/api/auth/entra/callback/route.ts b/src/app/api/auth/entra/callback/route.ts index 269af9b..5067da2 100644 --- a/src/app/api/auth/entra/callback/route.ts +++ b/src/app/api/auth/entra/callback/route.ts @@ -2,7 +2,6 @@ import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { withFlash } from "@/lib/flash"; import { prisma } from "@/lib/prisma"; -import { getBaseUrl } from "@/lib/url"; import { createSession, hashPassword, @@ -21,6 +20,7 @@ import { type EntraIdTokenClaims, } from "@/lib/entra"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { redirectWithinSite } from "@/lib/redirect"; const CALLBACK_ATTEMPTS_PER_WINDOW = 20; const CALLBACK_WINDOW_MS = 15 * 60 * 1000; @@ -48,10 +48,9 @@ function lastNameFromClaims(claims: EntraIdTokenClaims): string { * ausgeben und ein fremdes Konto uebernehmen/anlegen. */ export async function GET(request: Request) { - const baseUrl = getBaseUrl(); function redirectWithError(message: string): NextResponse { - return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message); + return withFlash(redirectWithinSite("/konto"), "error", message); } if (!isEntraConfigured()) { @@ -160,9 +159,9 @@ export async function GET(request: Request) { if (customer.role === "ADMIN" && customer.twoFactorEnabled) { const challengeToken = await signTwoFactorChallenge(customer.id); await setTwoFactorChallengeCookie(challengeToken); - return NextResponse.redirect(new URL("/konto/zwei-faktor-code", baseUrl)); + return redirectWithinSite("/konto/zwei-faktor-code"); } await createSession(customer.id, customer.role, customer.sessionVersion); - return NextResponse.redirect(new URL("/konto", baseUrl)); + return redirectWithinSite("/konto"); } diff --git a/src/app/api/auth/entra/login/route.ts b/src/app/api/auth/entra/login/route.ts index 514e6b7..fd5f645 100644 --- a/src/app/api/auth/entra/login/route.ts +++ b/src/app/api/auth/entra/login/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import { withFlash } from "@/lib/flash"; -import { getBaseUrl } from "@/lib/url"; import { setEntraOAuthCookie } from "@/lib/auth"; import { signEntraOAuthState } from "@/lib/session"; import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra"; +import { redirectWithinSite } from "@/lib/redirect"; /** * Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto): @@ -14,7 +14,7 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA export async function GET() { if (!isEntraConfigured()) { return withFlash( - NextResponse.redirect(new URL("/konto", getBaseUrl())), + redirectWithinSite("/konto"), "error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an." ); diff --git a/src/app/api/hinweis/route.ts b/src/app/api/hinweis/route.ts index ed1ff75..b764fed 100644 --- a/src/app/api/hinweis/route.ts +++ b/src/app/api/hinweis/route.ts @@ -1,7 +1,6 @@ -import { NextResponse } from "next/server"; import { withFlash } from "@/lib/flash-core"; import { NOTICES, isNoticeCode } from "@/lib/notice"; -import { getBaseUrl } from "@/lib/url"; +import { redirectWithinSite } from "@/lib/redirect"; /** * Zwischenstation fuer Weiterleitungen aus Server Components (Layouts, @@ -10,8 +9,7 @@ import { getBaseUrl } from "@/lib/url"; */ export async function GET(request: Request) { const code = new URL(request.url).searchParams.get("code"); - const baseUrl = getBaseUrl(); - if (!isNoticeCode(code)) return NextResponse.redirect(new URL("/", baseUrl)); + if (!isNoticeCode(code)) return redirectWithinSite("/"); const notice = NOTICES[code]; - return withFlash(NextResponse.redirect(new URL(notice.to, baseUrl)), "error", await notice.message()); + return withFlash(redirectWithinSite(notice.to), "error", await notice.message()); } diff --git a/src/app/api/konto/email-bestaetigen/route.ts b/src/app/api/konto/email-bestaetigen/route.ts index f8731bf..37b8ed5 100644 --- a/src/app/api/konto/email-bestaetigen/route.ts +++ b/src/app/api/konto/email-bestaetigen/route.ts @@ -1,13 +1,11 @@ -import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { hashVerificationToken } from "@/lib/email-verification"; -import { getBaseUrl } from "@/lib/url"; import { withFlash } from "@/lib/flash"; +import { redirectWithinSite } from "@/lib/redirect"; export async function GET(request: Request) { const token = new URL(request.url).searchParams.get("token"); - const baseUrl = getBaseUrl(); - const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message); + const fail = (message: string) => withFlash(redirectWithinSite("/konto"), "error", message); if (!token) { return fail("Dieser Bestätigungslink ist ungültig."); @@ -35,7 +33,7 @@ export async function GET(request: Request) { ]); return withFlash( - NextResponse.redirect(`${baseUrl}/konto`), + redirectWithinSite("/konto"), "success", "Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen." ); diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts index a5adbff..80eea39 100644 --- a/src/app/api/newsletter/confirm/route.ts +++ b/src/app/api/newsletter/confirm/route.ts @@ -1,19 +1,17 @@ -import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { getBaseUrl } from "@/lib/url"; import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount"; +import { redirectWithinSite } from "@/lib/redirect"; export async function GET(request: Request) { const token = new URL(request.url).searchParams.get("token"); - const baseUrl = getBaseUrl(); if (!token) { - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`); + return redirectWithinSite("/newsletter-bestaetigt?status=invalid"); } const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } }); if (!subscriber) { - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`); + return redirectWithinSite("/newsletter-bestaetigt?status=invalid"); } await prisma.newsletterSubscriber.update({ @@ -22,5 +20,5 @@ export async function GET(request: Request) { }); await ensureNewsletterDiscountCode(); - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=ok`); + return redirectWithinSite("/newsletter-bestaetigt?status=ok"); } diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts index 6ce3fee..1bda676 100644 --- a/src/app/api/newsletter/unsubscribe/route.ts +++ b/src/app/api/newsletter/unsubscribe/route.ts @@ -1,18 +1,16 @@ -import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { getBaseUrl } from "@/lib/url"; +import { redirectWithinSite } from "@/lib/redirect"; export async function GET(request: Request) { const token = new URL(request.url).searchParams.get("token"); - const baseUrl = getBaseUrl(); if (!token) { - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`); + return redirectWithinSite("/newsletter-bestaetigt?status=invalid"); } const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } }); if (!subscriber) { - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`); + return redirectWithinSite("/newsletter-bestaetigt?status=invalid"); } await prisma.newsletterSubscriber.update({ @@ -20,5 +18,5 @@ export async function GET(request: Request) { data: { unsubscribedAt: new Date() }, }); - return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=unsubscribed`); + return redirectWithinSite("/newsletter-bestaetigt?status=unsubscribed"); } diff --git a/src/lib/redirect.test.ts b/src/lib/redirect.test.ts new file mode 100644 index 0000000..527d7ea --- /dev/null +++ b/src/lib/redirect.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from "vitest"; +import { redirectWithinSite } from "./redirect"; + +describe("redirectWithinSite", () => { + test("setzt einen relativen Location-Header statt einer absoluten Domain", () => { + const response = redirectWithinSite("/konto"); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("/konto"); + }); + + test("behaelt Query-Parameter bei", () => { + const response = redirectWithinSite("/newsletter-bestaetigt?status=ok"); + expect(response.headers.get("location")).toBe("/newsletter-bestaetigt?status=ok"); + }); + + test("laesst sich mit Cookies (Flash-Meldung) versehen", () => { + const response = redirectWithinSite("/konto"); + response.cookies.set("probe", "1"); + expect(response.headers.get("set-cookie")).toContain("probe=1"); + }); + + test.each(["https://evil.example/konto", "//evil.example/konto", "konto", "/\\evil.example"])( + "lehnt %s ab (keine offene Weiterleitung)", + (target) => { + expect(() => redirectWithinSite(target)).toThrow(); + } + ); +}); diff --git a/src/lib/redirect.ts b/src/lib/redirect.ts new file mode 100644 index 0000000..a599802 --- /dev/null +++ b/src/lib/redirect.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +/** + * Weiterleitung innerhalb der Seite aus einem Route-Handler — mit relativem + * Location-Header ("/konto"), den der Browser gegen die Adresse aufloest, unter + * der er die Seite gerade aufgerufen hat. + * + * Gegenstueck zu redirect("/konto") in Server Actions und Seiten, das ebenfalls + * relativ weiterleitet. Frueher bauten einige Route-Handler das Ziel mit + * getBaseUrl() absolut zusammen: ohne gesetztes NEXT_PUBLIC_SITE_URL (lokal, + * Staging, Vorschau-Host) sprang man damit auf die Produktionsseite, und die + * gerade gesetzte Flash-Meldung ging auf dem fremden Host verloren. + * + * Absolut bleiben nur Adressen, die die Seite verlassen: E-Mail-Links, + * Stripe-Ruecksprung-URLs, die Entra-redirect_uri, Sitemap und Canonicals — + * dafuer ist getSiteUrl() da (lib/url.ts). + */ +export function redirectWithinSite(path: string, status: 303 | 307 = 307): NextResponse { + // Nur echte Pfade dieser Seite: "//host" und "/\host" werten Browser als + // Adresse auf einem fremden Host — das waere eine offene Weiterleitung. + if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) { + throw new Error(`redirectWithinSite erwartet einen Pfad dieser Seite, bekam: ${path}`); + } + return new NextResponse(null, { status, headers: { Location: path } }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 664cf7b..45b2562 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -73,6 +73,9 @@ export async function proxy(request: NextRequest) { return withCsp( withFlash( + // Absolut gegen den eigenen Host (request.url), nicht gegen getSiteUrl(): + // der Proxy verlangt einen absoluten Location-Header — ein relativer wie + // bei redirectWithinSite() (lib/redirect.ts) endet hier in "Invalid URL". NextResponse.redirect(new URL("/konto", request.url)), "error", session