Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fe70e957 |
No files matched your search
+3
-3
@@ -75,7 +75,7 @@
|
||||
"motion": "14.6.0",
|
||||
"music-metadata": "12.2.3",
|
||||
"mysql2": "3.24.6",
|
||||
"next": "16.4.2",
|
||||
"next": "16.4.3",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
@@ -83,7 +83,7 @@
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-hook-form": "7.90.0",
|
||||
"react-hook-form": "7.91.0",
|
||||
"resend": "6.34.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
@@ -94,7 +94,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.14.0",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@babel/parser": "8.0.8",
|
||||
"@biomejs/biome": "2.6.2",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
|
||||
Generated
+2429
-4481
File diff suppressed because it is too large.
Load diff
@@ -35,12 +35,4 @@ 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();
|
||||
});
|
||||
});
|
||||
+4
-10
@@ -2,13 +2,8 @@
|
||||
* 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,
|
||||
{ secure = true }: { secure?: boolean } = {},
|
||||
): string {
|
||||
export function buildContentSecurityPolicy(nonce: string): string {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const scriptSrc = [
|
||||
"'self'",
|
||||
@@ -35,16 +30,15 @@ export function buildContentSecurityPolicy(
|
||||
"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
|
||||
// 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.
|
||||
// production-only: over plain-HTTP localhost it would rewrite same-origin
|
||||
// asset URLs to https and break the dev server.
|
||||
"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 || !secure ? [] : ["upgrade-insecure-requests"]),
|
||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authUsesSecureCookies,
|
||||
isCacheableAssetPath,
|
||||
isHttpsRequest,
|
||||
shouldRedirectAdminRequest,
|
||||
} from "./proxy-access";
|
||||
|
||||
@@ -39,32 +37,3 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -11,37 +11,6 @@ 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 {
|
||||
|
||||
+2
-14
@@ -5,10 +5,8 @@ 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";
|
||||
|
||||
@@ -45,19 +43,11 @@ 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,
|
||||
// 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,
|
||||
),
|
||||
secureCookie: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -70,9 +60,7 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
||||
}
|
||||
|
||||
const nonce = createCspNonce();
|
||||
const csp = buildContentSecurityPolicy(nonce, {
|
||||
secure: isHttpsRequest(forwardedProto, req.nextUrl.protocol),
|
||||
});
|
||||
const csp = buildContentSecurityPolicy(nonce);
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("x-pathname", pathname);
|
||||
|
||||
Reference in new issue
Block a user