From fa34efd3f0b5c12215e3ac4642a5c5140ed3ba9e Mon Sep 17 00:00:00 2001 From: simo Date: Wed, 28 Oct 2026 22:00:20 +0100 Subject: [PATCH] fix(proxy): keep admin login and assets working on plain-HTTP hosts The /admin guard always read the `__Secure-` session cookie, but Auth.js only uses that name when AUTH_URL (or the request) is HTTPS. With an http:// AUTH_URL a signed-in admin was bounced between /admin and /login until the browser threw "too many history calls". The guard now picks the cookie name the same way Auth.js does. The CSP also sent `upgrade-insecure-requests` to plain-HTTP visitors, so the browser rewrote same-origin fonts/scripts to https:// where nothing listens. It is now only sent when the visitor reached us over HTTPS. Co-Authored-By: Claude Opus 5.5 --- src/lib/csp.test.ts | 8 ++++++++ src/lib/csp.ts | 14 ++++++++++---- src/lib/proxy-access.test.ts | 31 +++++++++++++++++++++++++++++++ src/lib/proxy-access.ts | 31 +++++++++++++++++++++++++++++++ src/proxy.ts | 16 ++++++++++++++-- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/lib/csp.test.ts b/src/lib/csp.test.ts index 0165f127..7286e07f 100644 --- a/src/lib/csp.test.ts +++ b/src/lib/csp.test.ts @@ -35,4 +35,12 @@ describe("csp", () => { expect(csp).not.toContain("upgrade-insecure-requests"); vi.unstubAllEnvs(); }); + + it("omits upgrade-insecure-requests for plain-HTTP visitors in production", () => { + vi.stubEnv("NODE_ENV", "production"); + const csp = buildContentSecurityPolicy("n", { secure: false }); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain("upgrade-insecure-requests"); + vi.unstubAllEnvs(); + }); }); diff --git a/src/lib/csp.ts b/src/lib/csp.ts index 417d7e40..a5bbafcb 100644 --- a/src/lib/csp.ts +++ b/src/lib/csp.ts @@ -2,8 +2,13 @@ * Build a Content-Security-Policy value. * Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags. * style-src-attr keeps 'unsafe-inline' so React `style={{…}}` attributes work. + * `secure` reports whether the visitor reached us over HTTPS; it gates + * `upgrade-insecure-requests`. */ -export function buildContentSecurityPolicy(nonce: string): string { +export function buildContentSecurityPolicy( + nonce: string, + { secure = true }: { secure?: boolean } = {}, +): string { const isDev = process.env.NODE_ENV === "development"; const scriptSrc = [ "'self'", @@ -30,15 +35,16 @@ export function buildContentSecurityPolicy(nonce: string): string { "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", // Clickjacking defence that also covers the `frame-ancestors`-aware // browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is - // production-only: over plain-HTTP localhost it would rewrite same-origin - // asset URLs to https and break the dev server. + // only sent to HTTPS visitors outside development: over plain HTTP + // (localhost, a LAN/WSL IP without TLS) it rewrites same-origin asset + // URLs to https, where nothing listens, and fonts/scripts fail to load. "frame-ancestors 'none'", "connect-src 'self' https: wss:", "font-src 'self' data: https://fonts.gstatic.com", "object-src 'none'", "base-uri 'self'", "form-action 'self'", - ...(isDev ? [] : ["upgrade-insecure-requests"]), + ...(isDev || !secure ? [] : ["upgrade-insecure-requests"]), ].join("; "); } diff --git a/src/lib/proxy-access.test.ts b/src/lib/proxy-access.test.ts index e5514076..e036af62 100644 --- a/src/lib/proxy-access.test.ts +++ b/src/lib/proxy-access.test.ts @@ -1,7 +1,9 @@ // @ts-nocheck import { describe, expect, it } from "vitest"; import { + authUsesSecureCookies, isCacheableAssetPath, + isHttpsRequest, shouldRedirectAdminRequest, } from "./proxy-access"; @@ -37,3 +39,32 @@ describe("isCacheableAssetPath", () => { expect(isCacheableAssetPath("/admin/media")).toBe(false); }); }); + +describe("isHttpsRequest", () => { + it("trusts the first forwarded proto over the internal hop", () => { + expect(isHttpsRequest("https", "http:")).toBe(true); + expect(isHttpsRequest("https, http", "http:")).toBe(true); + expect(isHttpsRequest("http", "http:")).toBe(false); + }); + + it("falls back to the URL Next saw", () => { + expect(isHttpsRequest(null, "https:")).toBe(true); + expect(isHttpsRequest(null, "http:")).toBe(false); + }); +}); + +describe("authUsesSecureCookies", () => { + it("follows AUTH_URL when it is set, like Auth.js", () => { + expect(authUsesSecureCookies("http://172.23.71.35", "https", "http:")).toBe( + false, + ); + expect( + authUsesSecureCookies("https://hotel.example", "http", "http:"), + ).toBe(true); + }); + + it("uses the request scheme without AUTH_URL", () => { + expect(authUsesSecureCookies(undefined, "https", "http:")).toBe(true); + expect(authUsesSecureCookies(undefined, null, "http:")).toBe(false); + }); +}); diff --git a/src/lib/proxy-access.ts b/src/lib/proxy-access.ts index 91ce7296..5af5392f 100644 --- a/src/lib/proxy-access.ts +++ b/src/lib/proxy-access.ts @@ -11,6 +11,37 @@ export function shouldRedirectAdminRequest( return token === null; } +/** + * Whether the visitor reached us over HTTPS. Behind Traefik/nginx Next only + * sees plain HTTP, so the first `X-Forwarded-Proto` value wins. + */ +export function isHttpsRequest( + forwardedProto: string | null, + requestProtocol: string, +): boolean { + const proto = forwardedProto?.split(",")[0]?.trim() || requestProtocol; + return proto.replace(/:$/, "").toLowerCase() === "https"; +} + +/** + * Mirrors how Auth.js picks the session cookie name (`__Secure-` prefix or + * not): AUTH_URL decides when set, otherwise the request scheme does. + */ +export function authUsesSecureCookies( + authUrl: string | undefined, + forwardedProto: string | null, + requestProtocol: string, +): boolean { + if (authUrl) { + try { + return new URL(authUrl).protocol === "https:"; + } catch { + // Auth.js would reject a malformed AUTH_URL too; fall through. + } + } + return isHttpsRequest(forwardedProto, requestProtocol); +} + const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"]; export function isCacheableAssetPath(pathname: string): boolean { diff --git a/src/proxy.ts b/src/proxy.ts index 5570392f..e6015e5b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,8 +5,10 @@ import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp"; import { isSuspiciousPath } from "@/lib/ddos"; import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard"; import { + authUsesSecureCookies, isCacheableAssetPath, isEdgeCacheablePublicPath, + isHttpsRequest, shouldRedirectAdminRequest, } from "@/lib/proxy-access"; @@ -43,11 +45,19 @@ export const proxy = async (req: import("next/server").NextRequest) => { // Only /admin needs a real session token for the redirect guard; skipping // JWT decoding on every other request keeps the proxy cheap under load. const adminPath = pathname === "/admin" || pathname.startsWith("/admin/"); + const forwardedProto = req.headers.get("x-forwarded-proto"); const token = adminPath ? await getToken({ req, secret: env.AUTH_SECRET, - secureCookie: true, + // Must match the cookie name Auth.js chose at sign-in, otherwise a + // signed-in admin on a plain-HTTP AUTH_URL is bounced between + // /admin and /login forever. + secureCookie: authUsesSecureCookies( + process.env.AUTH_URL ?? process.env.NEXTAUTH_URL, + forwardedProto, + req.nextUrl.protocol, + ), }) : null; @@ -60,7 +70,9 @@ export const proxy = async (req: import("next/server").NextRequest) => { } const nonce = createCspNonce(); - const csp = buildContentSecurityPolicy(nonce); + const csp = buildContentSecurityPolicy(nonce, { + secure: isHttpsRequest(forwardedProto, req.nextUrl.protocol), + }); const headers = new Headers(req.headers); headers.set("x-pathname", pathname);