fix(proxy): keep admin login and assets working on plain-HTTP hosts
CI / check (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / check (pull_request) Successful in 38s
CI / tests-imaging-browsers (pull_request) Skipped
CI / tests-quality (pull_request) Skipped
CI / tests-unit (pull_request) Failing after 36s
CI / tests-integration (pull_request) Successful in 2m36s
CI / tests-ui (pull_request) Successful in 6m21s
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
CI / check (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / check (pull_request) Successful in 38s
CI / tests-imaging-browsers (pull_request) Skipped
CI / tests-quality (pull_request) Skipped
CI / tests-unit (pull_request) Failing after 36s
CI / tests-integration (pull_request) Successful in 2m36s
CI / tests-ui (pull_request) Successful in 6m21s
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
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 <[email protected]>
This commit is contained in:
1 parent
b816026bd3
commit
fa34efd3f0
5 files changed
+94
-6
No files matched your search
@@ -35,4 +35,12 @@ describe("csp", () => {
|
|||||||
expect(csp).not.toContain("upgrade-insecure-requests");
|
expect(csp).not.toContain("upgrade-insecure-requests");
|
||||||
vi.unstubAllEnvs();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+10
-4
@@ -2,8 +2,13 @@
|
|||||||
* Build a Content-Security-Policy value.
|
* Build a Content-Security-Policy value.
|
||||||
* Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags.
|
* Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags.
|
||||||
* style-src-attr keeps 'unsafe-inline' so React `style={{…}}` attributes work.
|
* 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 isDev = process.env.NODE_ENV === "development";
|
||||||
const scriptSrc = [
|
const scriptSrc = [
|
||||||
"'self'",
|
"'self'",
|
||||||
@@ -30,15 +35,16 @@ export function buildContentSecurityPolicy(nonce: string): string {
|
|||||||
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
||||||
// Clickjacking defence that also covers the `frame-ancestors`-aware
|
// Clickjacking defence that also covers the `frame-ancestors`-aware
|
||||||
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
|
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
|
||||||
// production-only: over plain-HTTP localhost it would rewrite same-origin
|
// only sent to HTTPS visitors outside development: over plain HTTP
|
||||||
// asset URLs to https and break the dev server.
|
// (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'",
|
"frame-ancestors 'none'",
|
||||||
"connect-src 'self' https: wss:",
|
"connect-src 'self' https: wss:",
|
||||||
"font-src 'self' data: https://fonts.gstatic.com",
|
"font-src 'self' data: https://fonts.gstatic.com",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
...(isDev || !secure ? [] : ["upgrade-insecure-requests"]),
|
||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
authUsesSecureCookies,
|
||||||
isCacheableAssetPath,
|
isCacheableAssetPath,
|
||||||
|
isHttpsRequest,
|
||||||
shouldRedirectAdminRequest,
|
shouldRedirectAdminRequest,
|
||||||
} from "./proxy-access";
|
} from "./proxy-access";
|
||||||
|
|
||||||
@@ -37,3 +39,32 @@ describe("isCacheableAssetPath", () => {
|
|||||||
expect(isCacheableAssetPath("/admin/media")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,37 @@ export function shouldRedirectAdminRequest(
|
|||||||
return token === null;
|
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/"];
|
const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"];
|
||||||
|
|
||||||
export function isCacheableAssetPath(pathname: string): boolean {
|
export function isCacheableAssetPath(pathname: string): boolean {
|
||||||
|
|||||||
+14
-2
@@ -5,8 +5,10 @@ import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
|
|||||||
import { isSuspiciousPath } from "@/lib/ddos";
|
import { isSuspiciousPath } from "@/lib/ddos";
|
||||||
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||||
import {
|
import {
|
||||||
|
authUsesSecureCookies,
|
||||||
isCacheableAssetPath,
|
isCacheableAssetPath,
|
||||||
isEdgeCacheablePublicPath,
|
isEdgeCacheablePublicPath,
|
||||||
|
isHttpsRequest,
|
||||||
shouldRedirectAdminRequest,
|
shouldRedirectAdminRequest,
|
||||||
} from "@/lib/proxy-access";
|
} 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
|
// Only /admin needs a real session token for the redirect guard; skipping
|
||||||
// JWT decoding on every other request keeps the proxy cheap under load.
|
// JWT decoding on every other request keeps the proxy cheap under load.
|
||||||
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||||
|
const forwardedProto = req.headers.get("x-forwarded-proto");
|
||||||
const token = adminPath
|
const token = adminPath
|
||||||
? await getToken({
|
? await getToken({
|
||||||
req,
|
req,
|
||||||
secret: env.AUTH_SECRET,
|
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;
|
: null;
|
||||||
|
|
||||||
@@ -60,7 +70,9 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nonce = createCspNonce();
|
const nonce = createCspNonce();
|
||||||
const csp = buildContentSecurityPolicy(nonce);
|
const csp = buildContentSecurityPolicy(nonce, {
|
||||||
|
secure: isHttpsRequest(forwardedProto, req.nextUrl.protocol),
|
||||||
|
});
|
||||||
|
|
||||||
const headers = new Headers(req.headers);
|
const headers = new Headers(req.headers);
|
||||||
headers.set("x-pathname", pathname);
|
headers.set("x-pathname", pathname);
|
||||||
|
|||||||
Reference in new issue
Block a user