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 { NextResponse } from "next/server";
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
type EntraIdTokenClaims,
|
type EntraIdTokenClaims,
|
||||||
} from "@/lib/entra";
|
} from "@/lib/entra";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
const CALLBACK_ATTEMPTS_PER_WINDOW = 20;
|
const CALLBACK_ATTEMPTS_PER_WINDOW = 20;
|
||||||
const CALLBACK_WINDOW_MS = 15 * 60 * 1000;
|
const CALLBACK_WINDOW_MS = 15 * 60 * 1000;
|
||||||
@@ -48,10 +48,9 @@ function lastNameFromClaims(claims: EntraIdTokenClaims): string {
|
|||||||
* ausgeben und ein fremdes Konto uebernehmen/anlegen.
|
* ausgeben und ein fremdes Konto uebernehmen/anlegen.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
function redirectWithError(message: string): NextResponse {
|
function redirectWithError(message: string): NextResponse {
|
||||||
return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
|
return withFlash(redirectWithinSite("/konto"), "error", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
@@ -160,9 +159,9 @@ export async function GET(request: Request) {
|
|||||||
if (customer.role === "ADMIN" && customer.twoFactorEnabled) {
|
if (customer.role === "ADMIN" && customer.twoFactorEnabled) {
|
||||||
const challengeToken = await signTwoFactorChallenge(customer.id);
|
const challengeToken = await signTwoFactorChallenge(customer.id);
|
||||||
await setTwoFactorChallengeCookie(challengeToken);
|
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);
|
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 { NextResponse } from "next/server";
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { setEntraOAuthCookie } from "@/lib/auth";
|
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||||
import { signEntraOAuthState } from "@/lib/session";
|
import { signEntraOAuthState } from "@/lib/session";
|
||||||
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
|
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
|
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
|
||||||
@@ -14,7 +14,7 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
return withFlash(
|
return withFlash(
|
||||||
NextResponse.redirect(new URL("/konto", getBaseUrl())),
|
redirectWithinSite("/konto"),
|
||||||
"error",
|
"error",
|
||||||
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
"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 { withFlash } from "@/lib/flash-core";
|
||||||
import { NOTICES, isNoticeCode } from "@/lib/notice";
|
import { NOTICES, isNoticeCode } from "@/lib/notice";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
|
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
|
||||||
@@ -10,8 +9,7 @@ import { getBaseUrl } from "@/lib/url";
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const code = new URL(request.url).searchParams.get("code");
|
const code = new URL(request.url).searchParams.get("code");
|
||||||
const baseUrl = getBaseUrl();
|
if (!isNoticeCode(code)) return redirectWithinSite("/");
|
||||||
if (!isNoticeCode(code)) return NextResponse.redirect(new URL("/", baseUrl));
|
|
||||||
const notice = NOTICES[code];
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { hashVerificationToken } from "@/lib/email-verification";
|
import { hashVerificationToken } from "@/lib/email-verification";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
const fail = (message: string) => withFlash(redirectWithinSite("/konto"), "error", message);
|
||||||
const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||||
@@ -35,7 +33,7 @@ export async function GET(request: Request) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return withFlash(
|
return withFlash(
|
||||||
NextResponse.redirect(`${baseUrl}/konto`),
|
redirectWithinSite("/konto"),
|
||||||
"success",
|
"success",
|
||||||
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
"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 { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
if (!token) {
|
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 } });
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.newsletterSubscriber.update({
|
await prisma.newsletterSubscriber.update({
|
||||||
@@ -22,5 +20,5 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
await ensureNewsletterDiscountCode();
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
if (!token) {
|
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 } });
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.newsletterSubscriber.update({
|
await prisma.newsletterSubscriber.update({
|
||||||
@@ -20,5 +18,5 @@ export async function GET(request: Request) {
|
|||||||
data: { unsubscribedAt: new Date() },
|
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(
|
return withCsp(
|
||||||
withFlash(
|
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)),
|
NextResponse.redirect(new URL("/konto", request.url)),
|
||||||
"error",
|
"error",
|
||||||
session
|
session
|
||||||
|
|||||||
Reference in new issue
Block a user