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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 01:11:44 +02:00
1 parent 4f442ec06d
commit 52ce37e216
9 files changed
+76 -29

No files matched your search

+4 -5
View File
@@ -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");
}
+2 -2
View File
@@ -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."
);
+3 -5
View File
@@ -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());
}
+3 -5
View File
@@ -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."
);
+4 -6
View File
@@ -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");
}
+4 -6
View File
@@ -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");
}
+28
View File
@@ -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();
}
);
});
+25
View File
@@ -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 } });
}
+3
View File
@@ -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