Files
Wabenhain/src/proxy.ts
T
Nick SzaunigandClaude Opus 5.5 4ee5bafe20 feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:01:18 +02:00

104 lines
4.3 KiB
TypeScript

import { withFlash } from "@/lib/flash-core";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
const isDev = process.env.NODE_ENV === "development";
/**
* Nonce-basierte CSP statt 'unsafe-inline' fuer script-src: pro Request neu
* erzeugt, Next.js wendet sie automatisch auf eigene Framework-/Seiten-Skripte
* an (erkennt das 'nonce-...'-Muster im CSP-Response-Header waehrend des
* Renderns). 'strict-dynamic' erlaubt zusaetzlich, dass von einem bereits
* vertrauten Skript (z.B. Next.js' eigener Runtime) dynamisch nachgeladene
* Skripte (next/script mit strategy="afterInteractive", z.B. das
* Turnstile-Widget) automatisch mitvertraut werden, ohne jedes einzeln nonce'n
* zu muessen. JSON-LD-<script type="application/ld+json">-Bloecke sind von
* script-src ohnehin nie betroffen (kein ausfuehrbarer Skript-Typ).
* style-src behaelt bewusst 'unsafe-inline': computed inline styles (Admin-
* Dashboard-Balkendiagramme, globale Fehlerseite ohne CSS-Abhaengigkeit)
* muessten sonst einzeln genonced werden, Style-Injection ist zudem ein
* deutlich schwaecheres Angriffsprimitiv als Skript-Injection.
*/
function buildCsp(nonce: string): string {
return [
"default-src 'self'",
// Stripe-Hosts laut https://docs.stripe.com/security/guide (Abschnitte
// "Checkout" und "Stripe.js") fuer den eingebetteten Checkout. In
// script-src greifen sie nur in Browsern ohne 'strict-dynamic'; die
// uebrigen laden Stripe.js, weil ein genoncetes Skript es nachlaedt.
// hooks.stripe.com: 3-D-Secure und Weiterleitungs-Zahlungsarten.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com https://js.stripe.com https://*.js.stripe.com https://checkout.stripe.com${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.stripe.com",
"font-src 'self' data:",
"connect-src 'self' https://api.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com",
"frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
].join("; ");
}
/**
* Zugangskontrolle fuer den internen /admin-Bereich: Statt Basic Auth
* entscheidet das normale Kundenkonto-Login. Nur Sessions, deren JWT die
* Rolle ADMIN traegt, kommen durch — alle anderen werden auf die
* Login-Seite (/konto) umgeleitet.
*
* Das JWT-Claim ist dabei nur der schnelle Vorfilter auf Request-Ebene;
* die autoritative Datenbank-Pruefung passiert zusaetzlich im Admin-Layout
* und in jeder Admin-Server-Action (getCurrentAdmin).
*/
export async function proxy(request: NextRequest) {
const nonce = crypto.randomUUID().replaceAll("-", "");
const cspHeader = buildCsp(nonce);
function withCsp(response: NextResponse): NextResponse {
response.headers.set("Content-Security-Policy", cspHeader);
return response;
}
if (request.nextUrl.pathname.startsWith("/admin")) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
const session = token ? await verifySessionToken(token) : null;
if (session?.role !== "ADMIN") {
// Server Actions und API-artige Aufrufe bekommen einen harten 403 statt
// einer Redirect-HTML-Seite.
if (request.method !== "GET") {
return withCsp(new NextResponse("Zugriff verweigert.", { status: 403 }));
}
return withCsp(
withFlash(
NextResponse.redirect(new URL("/konto", request.url)),
"error",
session
? "Dein Konto hat keinen Zugriff auf den Admin-Bereich."
: "Bitte melde dich an, um den Admin-Bereich zu nutzen."
)
);
}
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", cspHeader);
return withCsp(NextResponse.next({ request: { headers: requestHeaders } }));
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};