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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf2026-11-06 09:32:28 +0300
committerGitHub2026-11-06 09:32:28 +0300
commit8f18eba55a8980141bd7b989378cb557ae927e2e (patch)
tree6e5e9b36161934b3e646ea21ccc00407cf29ba0f
parent35e09214817585007308bdbc26d877791843ab29 (diff)
parentc4fb3effa7f806c570771bb83c0ed6988006bc24 (diff)
downloadprogmaweb-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.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