fix: Login komplett kaputt seit dem Security-Header-Commit - CSP blockierte jede API-Anfrage
Root Cause, zweifach:
1. proxy.ts lag im Projekt-Root statt unter src/ (wo app/ tatsaechlich
liegt). Next.js hat die Datei dadurch nie erkannt - ueberprueft via
Build-Output ("Proxy (Middleware)" fehlte komplett) und via
routes-manifest.json. Das betraf nicht nur die neuen
Security-Header, sondern auch die schon laenger bestehende
Login-Redirect-Logik, die dadurch vermutlich nie serverseitig
gegriffen hat - vorher unbemerkt, weil Client-seitige Auth-Checks
das kaschiert haben.
2. Die CSP (mit dynamischem connect-src aus API_URL) lag in
next.config.ts's headers() - die wird beim Docker-Build EINMAL
aufgeloest und ins Standalone-Output eingefroren, nicht bei jedem
Container-Start neu gelesen, wie im Code-Kommentar faelschlich
behauptet. Jedes Deployment bekam dadurch permanent
"connect-src http://localhost:3001" (den Fallback, da API_URL beim
Build nicht gesetzt ist) - unabhaengig vom tatsaechlich im Pod
gesetzten API_URL. Jede Anfrage vom Frontend ans Backend (Login,
SSO-Status, Herkunftsseite) wurde dadurch von jedem Browser
CSP-blockiert.
Fix: CSP + Security-Header ziehen nach proxy.ts (jetzt korrekt unter
src/) um - Proxy laeuft mit Node.js-Runtime frisch bei jeder Anfrage,
liest process.env.API_URL also korrekt zur Laufzeit. next.config.ts
behaelt nur noch die statische sw.js-Cache-Control-Regel.
Lokal end-to-end verifiziert: Build zeigt jetzt "ƒ Proxy (Middleware)"
(vorher komplett abwesend), CSP-Header zeigt nach Server-Start einen
distinct getesteten API_URL-Wert korrekt an (nicht mehr den
Localhost-Fallback), und ein echter Login-Klick im Browser schickt die
Anfrage jetzt an die richtige, zur Laufzeit gesetzte Backend-Origin.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
cd24523ea5
commit
c95e1c4b18
3 files changed
+105
-81
No files matched your search
@@ -1,44 +1,14 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
// Runs when the Node server process starts inside the container, so this
|
// Security headers (CSP, X-Frame-Options, etc.) live in proxy.ts, not here.
|
||||||
// correctly reads the deployment's actual runtime API_URL (see
|
// This file's headers() is resolved once at build time and frozen into the
|
||||||
// src/lib/config/runtime-config.ts for why API_URL is a runtime env var
|
// standalone output - fine for a static value like the sw.js rule below, but
|
||||||
// rather than baked into the client bundle at build time) - this is server
|
// wrong for the CSP's connect-src, which needs the container's actual
|
||||||
// startup, not the browser bundle, so it isn't subject to that constraint.
|
// runtime API_URL. That mistake shipped a permanently-wrong connect-src
|
||||||
function apiOrigin(): string {
|
// (build-time env, if any, or the localhost fallback) to every environment
|
||||||
try {
|
// regardless of the real deployed API_URL, silently CSP-blocking every API
|
||||||
return new URL(process.env.API_URL ?? "http://localhost:3001").origin;
|
// call including login. Proxy runs fresh on every request and reads env
|
||||||
} catch {
|
// correctly - see proxy.ts for the real header logic.
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No CSP nonce here on purpose: nonce-based CSP requires every page to be
|
|
||||||
// dynamically rendered (Next.js CSP guide, "Static vs Dynamic Rendering"),
|
|
||||||
// which would drop this app's static optimization across the board. This
|
|
||||||
// policy still blocks the things that matter for a mostly-same-origin app -
|
|
||||||
// cross-origin exfiltration, clickjacking, base-tag/form hijacking - via
|
|
||||||
// 'unsafe-inline' only for script/style (needed for Next's own hydration
|
|
||||||
// data and Tailwind's inline styles), matching the same trade-off the
|
|
||||||
// backend's own helmet CSP already makes for style-src.
|
|
||||||
function cspHeader(): string {
|
|
||||||
return `
|
|
||||||
default-src 'self';
|
|
||||||
script-src 'self' 'unsafe-inline';
|
|
||||||
style-src 'self' 'unsafe-inline';
|
|
||||||
img-src 'self' blob: data:;
|
|
||||||
font-src 'self';
|
|
||||||
connect-src 'self' ${apiOrigin()};
|
|
||||||
object-src 'none';
|
|
||||||
base-uri 'self';
|
|
||||||
form-action 'self';
|
|
||||||
frame-ancestors 'self';
|
|
||||||
upgrade-insecure-requests;
|
|
||||||
`
|
|
||||||
.replace(/\s{2,}/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
async headers() {
|
||||||
@@ -52,16 +22,6 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/sw.js",
|
source: "/sw.js",
|
||||||
headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }],
|
headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: "/:path*",
|
|
||||||
headers: [
|
|
||||||
{ key: "Content-Security-Policy", value: cspHeader() },
|
|
||||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
|
||||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
||||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
|
||||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
const SESSION_COOKIE = "refreshToken";
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
|
||||||
const hasSession = request.cookies.has(SESSION_COOKIE);
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
|
|
||||||
if (pathname.startsWith("/login")) {
|
|
||||||
if (hasSession) {
|
|
||||||
return NextResponse.redirect(new URL("/settings/users", request.url));
|
|
||||||
}
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasSession) {
|
|
||||||
return NextResponse.redirect(new URL("/login", request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
"/login",
|
|
||||||
"/settings/:path*",
|
|
||||||
"/apiaries/:path*",
|
|
||||||
"/colonies/:path*",
|
|
||||||
"/queens/:path*",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "refreshToken";
|
||||||
|
|
||||||
|
// Paths that redirect to /login when there's no session - kept identical to
|
||||||
|
// the set this file already covered before security headers moved in here.
|
||||||
|
// Broadening this would silently change auth-redirect behaviour for pages
|
||||||
|
// that currently rely on client-side auth checks instead (dashboard,
|
||||||
|
// bottling, etc.) and must not now start doing it as a side effect of the
|
||||||
|
// matcher below being widened for headers.
|
||||||
|
const AUTH_REDIRECT_PATTERNS = [
|
||||||
|
/^\/settings(\/.*)?$/,
|
||||||
|
/^\/apiaries(\/.*)?$/,
|
||||||
|
/^\/colonies(\/.*)?$/,
|
||||||
|
/^\/queens(\/.*)?$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function needsAuthRedirectCheck(pathname: string): boolean {
|
||||||
|
return AUTH_REDIRECT_PATTERNS.some((pattern) => pattern.test(pathname));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately computed here, not in next.config.ts: that file's headers()
|
||||||
|
// is resolved once at build time and frozen into the standalone output, so
|
||||||
|
// process.env.API_URL there is whatever was (or wasn't) set during the
|
||||||
|
// Docker build - never the container's actual runtime value. Proxy runs the
|
||||||
|
// Node.js runtime fresh on every request, so this reads the real deployed
|
||||||
|
// API_URL correctly instead of silently falling back to localhost forever.
|
||||||
|
function apiOrigin(): string {
|
||||||
|
try {
|
||||||
|
return new URL(process.env.API_URL ?? "http://localhost:3001").origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cspHeader(): string {
|
||||||
|
return `
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' 'unsafe-inline';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' blob: data:;
|
||||||
|
font-src 'self';
|
||||||
|
connect-src 'self' ${apiOrigin()};
|
||||||
|
object-src 'none';
|
||||||
|
base-uri 'self';
|
||||||
|
form-action 'self';
|
||||||
|
frame-ancestors 'self';
|
||||||
|
upgrade-insecure-requests;
|
||||||
|
`
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSecurityHeaders(response: NextResponse): NextResponse {
|
||||||
|
response.headers.set("Content-Security-Policy", cspHeader());
|
||||||
|
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
response.headers.set("Referrer-Policy", "no-referrer");
|
||||||
|
response.headers.set(
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains",
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const hasSession = request.cookies.has(SESSION_COOKIE);
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/login")) {
|
||||||
|
if (hasSession) {
|
||||||
|
return withSecurityHeaders(
|
||||||
|
NextResponse.redirect(new URL("/settings/users", request.url)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return withSecurityHeaders(NextResponse.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsAuthRedirectCheck(pathname) && !hasSession) {
|
||||||
|
return withSecurityHeaders(
|
||||||
|
NextResponse.redirect(new URL("/login", request.url)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSecurityHeaders(NextResponse.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Broadened from the original 5-path list so every page gets the security
|
||||||
|
// headers (not just the ones that also happen to need the auth-redirect
|
||||||
|
// check, which is now handled separately above, unchanged in scope).
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|sw.js|manifest.webmanifest|icon.svg).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in new issue
Block a user