diff options
| author | Leitwolf | 2026-11-06 09:32:28 +0300 |
|---|---|---|
| committer | GitHub | 2026-11-06 09:32:28 +0300 |
| commit | 8f18eba55a8980141bd7b989378cb557ae927e2e (patch) | |
| tree | 6e5e9b36161934b3e646ea21ccc00407cf29ba0f | |
| parent | 35e09214817585007308bdbc26d877791843ab29 (diff) | |
| parent | c4fb3effa7f806c570771bb83c0ed6988006bc24 (diff) | |
| download | progmaweb-8f18eba55a8980141bd7b989378cb557ae927e2e.tar.gz | |
Merge pull request #55 from Progmasoft/security/content-security-policy
Give every page a Content-Security-Policy with its own nonce
| -rw-r--r-- | apps/web/app/layout.tsx | 9 | ||||
| -rw-r--r-- | apps/web/lib/content-security-policy.ts | 58 | ||||
| -rw-r--r-- | apps/web/proxy.ts | 57 | ||||
| -rw-r--r-- | apps/web/tests/content-security-policy.test.mjs | 54 | ||||
| -rw-r--r-- | docs/ARCHITECTURE.md | 5 |
5 files changed, 170 insertions, 13 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index db5826b..cfdfcde 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 import type { Metadata } from "next"; +import { headers } from "next/headers"; import type { ReactNode } from "react"; +import { nonceHeader } from "@/lib/content-security-policy"; import { getLocale } from "@/lib/locale.server"; import { textDirection } from "@/lib/localization"; import "./globals.css"; @@ -42,6 +44,8 @@ export default async function RootLayout({ children, }: Readonly<{ children: ReactNode }>) { const locale = await getLocale(); + // The proxy gives each page a nonce; its policy admits no other inline script. + const nonce = (await headers()).get(nonceHeader) ?? undefined; return ( <html lang={locale} @@ -52,7 +56,10 @@ export default async function RootLayout({ suppressHydrationWarning > <head> - <script dangerouslySetInnerHTML={{ __html: themeBootstrap }} /> + <script + nonce={nonce} + dangerouslySetInnerHTML={{ __html: themeBootstrap }} + /> </head> <body>{children}</body> </html> diff --git a/apps/web/lib/content-security-policy.ts b/apps/web/lib/content-security-policy.ts new file mode 100644 index 0000000..c7a285e --- /dev/null +++ b/apps/web/lib/content-security-policy.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +/** The request header that carries the nonce of a page to its layout. */ +export const nonceHeader = "x-nonce"; + +/** + * A fresh nonce for one response: 128 random bits in base64. + * + * @returns The nonce. + */ +export function createNonce(): string { + return btoa( + String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))), + ); +} + +/** + * The Content-Security-Policy of a page. Next marks its own inline scripts + * with the nonce when it reads this policy from the request; the theme + * bootstrap of the layout carries it too. + * + * Cloudflare adds its Web Analytics beacon to every page, from + * static.cloudflareinsights.com, reporting to cloudflareinsights.com and to + * /cdn-cgi/rum on the same host. 'strict-dynamic' would make browsers ignore + * that host, so the policy lists hosts instead. + * + * There is no form-action: a sign-in or consent form ends in a redirect to + * another Progmasoft host or to an OAuth client, including a loopback address, + * and browsers apply form-action to those redirects. + * + * @param nonce The nonce of the response. + * @param development Whether the development server runs, which evaluates code + * for its hot reload. + * @returns The value of the header. + */ +export function contentSecurityPolicy( + nonce: string, + development: boolean, +): string { + const scripts = [ + "'self'", + `'nonce-${nonce}'`, + "https://static.cloudflareinsights.com", + ...(development ? ["'unsafe-eval'"] : []), + ]; + return [ + "default-src 'self'", + `script-src ${scripts.join(" ")}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self' https://cloudflareinsights.com", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + ].join("; "); +} diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 6898962..4c09ce4 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -11,6 +11,11 @@ import { } from "@/lib/localization"; import { authorizeRoute, consoleRoute, issuerRoute } from "@/lib/credentials"; import { catch3Route, idesRoute, productRoute } from "@/lib/sites"; +import { + contentSecurityPolicy, + createNonce, + nonceHeader, +} from "@/lib/content-security-policy"; const accountHosts = new Set(["account.progmasoft.com", "account.localhost"]); const vigetHosts = new Set(["viget.progmasoft.com", "viget.localhost"]); @@ -57,6 +62,29 @@ const ownSites: OwnSite[] = [ ]; export function proxy(request: NextRequest) { + // Every page gets its own nonce. Next reads the policy from the request to + // mark its scripts, and the layout reads the nonce for the theme bootstrap. + const nonce = createNonce(); + const policy = contentSecurityPolicy( + nonce, + process.env.NODE_ENV === "development", + ); + const headers = new Headers(request.headers); + headers.set(nonceHeader, nonce); + headers.set("Content-Security-Policy", policy); + const response = route(request, headers); + response.headers.set("Content-Security-Policy", policy); + return response; +} + +/** + * Where a request goes on its host. + * + * @param request The request. + * @param headers The request headers the page renders with. + * @returns The response. + */ +function route(request: NextRequest, headers: Headers): NextResponse { const host = requestHost( request.headers.get("x-forwarded-host"), request.headers.get("host"), @@ -97,12 +125,12 @@ export function proxy(request: NextRequest) { const site = ownSites.find((candidate) => candidate.hosts.has(host)); if (site) { if (site.api && pathname.startsWith("/api/")) { - return NextResponse.next(); + return NextResponse.next({ request: { headers } }); } // The standalone server runs this proxy a second time for the rewritten // path, with the same host: a path already in the site's tree is final. if (pathname === site.root || pathname.startsWith(`${site.root}/`)) { - return NextResponse.next(); + return NextResponse.next({ request: { headers } }); } const destination = internalRoute( request, @@ -111,12 +139,12 @@ export function proxy(request: NextRequest) { // The sign-in page reads ReturnUrl and the consent page every parameter // of the authorization request. destination.search = request.nextUrl.search; - return NextResponse.rewrite(destination); + return rewrite(headers, destination); } // The internal trees belong to their hosts only. for (const { root } of ownSites) { if (pathname === root || pathname.startsWith(`${root}/`)) { - return NextResponse.rewrite(internalRoute(request, "/not-found")); + return rewrite(headers, internalRoute(request, "/not-found")); } } @@ -128,7 +156,7 @@ export function proxy(request: NextRequest) { // so an unmodified absolute rewrite would attempt TLS against its HTTP-only // loopback listener and fail before the account page can render. destination.protocol = "http:"; - return NextResponse.rewrite(destination); + return rewrite(headers, destination); } // ViGet is deployed by Progmaweb but keeps short public catalog URLs. The @@ -136,16 +164,17 @@ export function proxy(request: NextRequest) { // organization page and the account surface. if (vigetHosts.has(host)) { if (pathname === "/") { - return NextResponse.rewrite(internalRoute(request, "/viget")); + return rewrite(headers, internalRoute(request, "/viget")); } // Packages are grouped by the language they are written in and DSL // plugins by the build system they extend. A group without a catalog of // its own leads to the one catalog there is. if (pathname === "/langs/tala" || pathname === "/langs/tala/") { - return NextResponse.rewrite(internalRoute(request, "/viget/langs/tala")); + return rewrite(headers, internalRoute(request, "/viget/langs/tala")); } if (pathname === "/dslplugins/tazel" || pathname === "/dslplugins/tazel/") { - return NextResponse.rewrite( + return rewrite( + headers, internalRoute(request, "/viget/dslplugins/tazel"), ); } @@ -156,7 +185,8 @@ export function proxy(request: NextRequest) { pathname, ); if (plugin) { - return NextResponse.rewrite( + return rewrite( + headers, internalRoute( request, `/viget/dslplugins/tazel/${plugin[1]}/${plugin[2]}`, @@ -167,7 +197,8 @@ export function proxy(request: NextRequest) { pathname === "/dslplugins/gradle" || pathname === "/dslplugins/gradle/" ) { - return NextResponse.rewrite( + return rewrite( + headers, internalRoute(request, "/viget/dslplugins/gradle"), ); } @@ -215,7 +246,11 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(new URL("https://account.progmasoft.com/")); } - return NextResponse.next(); + return NextResponse.next({ request: { headers } }); +} + +function rewrite(headers: Headers, destination: URL): NextResponse { + return NextResponse.rewrite(destination, { request: { headers } }); } function internalRoute(request: NextRequest, pathname: string): URL { diff --git a/apps/web/tests/content-security-policy.test.mjs b/apps/web/tests/content-security-policy.test.mjs new file mode 100644 index 0000000..8b7a888 --- /dev/null +++ b/apps/web/tests/content-security-policy.test.mjs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + contentSecurityPolicy, + createNonce, +} from "../lib/content-security-policy.ts"; + +function directives(policy) { + return new Map( + policy.split("; ").map((directive) => { + const [name, ...values] = directive.split(" "); + return [name, values]; + }), + ); +} + +test("a nonce is 128 random bits and differs on every call", () => { + const nonces = new Set(Array.from({ length: 100 }, createNonce)); + assert.equal(nonces.size, 100); + for (const nonce of nonces) { + assert.match(nonce, /^[A-Za-z0-9+/]{22}==$/); + } +}); + +test("scripts run from this host, with the nonce, or from Cloudflare's beacon", () => { + const policy = directives(contentSecurityPolicy("abc", false)); + assert.deepEqual(policy.get("script-src"), [ + "'self'", + "'nonce-abc'", + "https://static.cloudflareinsights.com", + ]); + assert.deepEqual(policy.get("connect-src"), [ + "'self'", + "https://cloudflareinsights.com", + ]); + assert.deepEqual(policy.get("object-src"), ["'none'"]); + assert.deepEqual(policy.get("frame-ancestors"), ["'none'"]); + assert.deepEqual(policy.get("base-uri"), ["'self'"]); + // OAuth ends in redirects to other hosts after a form is sent. + assert.equal(policy.has("form-action"), false); + for (const values of policy.values()) { + assert.equal(values.includes("'unsafe-eval'"), false); + assert.equal(values.includes("'strict-dynamic'"), false); + } + assert.equal(policy.get("script-src").includes("'unsafe-inline'"), false); +}); + +test("only the development server may evaluate code", () => { + const policy = directives(contentSecurityPolicy("abc", true)); + assert.ok(policy.get("script-src").includes("'unsafe-eval'")); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b2b46d6..f5c812b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,7 +103,10 @@ task, not a public route. ## Response headers Nginx adds HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, a referrer policy and a permissions policy on -every host. Next.js sets the same content headers for the responses it produces. The API sets its own, stricter set: +every host. Next.js sets the same content headers for the responses it produces, and its proxy gives every page a +content security policy with a nonce of its own (`apps/web/lib/content-security-policy.ts`): scripts come from the +host, carry the nonce, or are Cloudflare's Web Analytics beacon; nothing may frame a page, and there is no +`form-action`, because sign-in and consent end in redirects to other hosts. The API sets its own, stricter set: a content security policy that allows nothing, `Cache-Control: no-store`, and no referrer. ## Shared account identity |