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:
1 parent
4f442ec06d
commit
52ce37e216
9 files changed
+76
-29
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user