Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-06 08:05:21 +0300
committerLeitwolf112026-11-06 08:05:21 +0300
commitc4fb3effa7f806c570771bb83c0ed6988006bc24 (patch)
tree6e5e9b36161934b3e646ea21ccc00407cf29ba0f
parent35e09214817585007308bdbc26d877791843ab29 (diff)
downloadprogmaweb-c4fb3effa7f806c570771bb83c0ed6988006bc24.tar.gz
Give every page a Content-Security-Policy with its own noncesecurity/content-security-policy
The pages of progmasoft.com and its hosts had no content security policy. The proxy now creates a 128-bit nonce for every request and sends the policy both ways: on the request, so that Next marks its own inline scripts with the nonce, and on the response. The layout reads the nonce for the theme bootstrap. Every page is rendered on demand already, so none is served with a stale nonce. Scripts may come from the host, carry the nonce, or be Cloudflare's Web Analytics beacon from static.cloudflareinsights.com, which reports to cloudflareinsights.com. 'strict-dynamic' is left out, because browsers ignore host sources under it and the beacon is added by Cloudflare without a nonce. Styles allow inline ones; objects, framing and other base URLs are refused. There is no form-action: sign-in and OAuth consent end in redirects to other hosts, loopback addresses included, and browsers apply form-action to them. The development server also gets 'unsafe-eval' for its hot reload. Tests check the nonce and each directive; ARCHITECTURE.md describes the policy.
-rw-r--r--apps/web/app/layout.tsx9
-rw-r--r--apps/web/lib/content-security-policy.ts58
-rw-r--r--apps/web/proxy.ts57
-rw-r--r--apps/web/tests/content-security-policy.test.mjs54
-rw-r--r--docs/ARCHITECTURE.md5
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