From 456c3b1b89b1f4d9e7008b0291d2211a78e72448 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Mon, 25 May 2026 23:09:54 +0200 Subject: [PATCH 1/3] feat(go/csp): AdminStrictPolicy preset for issue #59 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AdminStrictPolicy() which tightens AdminPolicy() in three ways required by the strict admin CSP shape: - script-src always includes 'strict-dynamic' so the per-request nonce transitively authorizes the Next.js runtime's dynamic chunks without a per-host allowlist. - require-trusted-types-for 'script' forced on. - trusted-types defaults to "gn-admin gn-editor 'allow-duplicates'" — the policy names the admin and block editor mint at runtime. Caller overrides (TrustedTypePolicies, IncludeStrictDynamic) still win so a hardened deployment can drop 'allow-duplicates' or constrain the list further. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- packages/go/middleware/csp/preset.go | 65 ++++++++++++++++++++ packages/go/middleware/csp/preset_test.go | 73 +++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/packages/go/middleware/csp/preset.go b/packages/go/middleware/csp/preset.go index 57c70c4a..6063b5a6 100644 --- a/packages/go/middleware/csp/preset.go +++ b/packages/go/middleware/csp/preset.go @@ -235,6 +235,71 @@ func AdminPolicy(opts PolicyOptions) *Policy { return p } +// AdminStrictPolicy returns the **admin-strict** CSP shape used by the +// GoNext admin Next.js app (issue #59). It tightens AdminPolicy in three +// security-meaningful ways while remaining permissive enough for the +// real-world admin chrome to load: +// +// - script-src includes 'strict-dynamic' so the per-request nonce +// transitively authorizes the Next.js runtime's dynamically-loaded +// chunks WITHOUT a per-host allowlist. This is the modern strict +// shape recommended by CSP3 §6.1 and is the only way the +// 'unsafe-inline' / host-allowlist trap is fully closed. +// - require-trusted-types-for 'script' is forced ON; Trusted Types +// enforcement is non-negotiable for the admin. +// - trusted-types lists exactly the policies the admin/editor minted: +// gn-admin (the global helper in apps/admin/src/lib/trusted-types.ts), +// gn-editor (the block editor's policy), plus 'allow-duplicates' so +// dev/Fast-Refresh paths that re-install the policy do not throw. +// +// Other directives mirror AdminPolicy: frame-ancestors 'none', +// object-src 'none', base-uri 'self', etc. Use AdminStrictPolicy when +// wiring the admin Next.js host; use AdminPolicy when shipping the +// looser "admin-style" policy to an embedded surface that has not yet +// converted to Trusted Types. +// +// The returned Policy is intended to be passed through Middleware so +// the per-request nonce is folded in via WithNonce. Do not mutate the +// returned value across requests. +func AdminStrictPolicy(opts PolicyOptions) *Policy { + p := AdminPolicy(opts) + + // strict-dynamic: forced ON for admin-strict. AdminPolicy defaults + // to OFF; we override here so callers cannot accidentally disable + // it by leaving IncludeStrictDynamic nil. Callers can still + // explicitly suppress it via IncludeStrictDynamic = &false (e.g. + // browser-compat testing), which AdminPolicy honored above. + if opts.IncludeStrictDynamic == nil { + hasStrictDynamic := false + for _, s := range p.ScriptSrc { + if s.kind == srcStrictDynamic { + hasStrictDynamic = true + break + } + } + if !hasStrictDynamic { + p.ScriptSrc = append(p.ScriptSrc, StrictDynamic()) + } + } + + // trusted-types: only override if the caller did not pass + // TrustedTypePolicies. The admin-strict policy names — gn-admin + // for the global setHTML/setURL helpers and gn-editor for the + // block editor's icon and rich-content sinks — plus the + // 'allow-duplicates' permission keyword so dev Fast-Refresh + // reloads don't error. + if len(opts.TrustedTypePolicies) == 0 { + p.TrustedTypes = []string{"gn-admin", "gn-editor", "'allow-duplicates'"} + } + + // require-trusted-types-for: forced ON. + if len(p.RequireTrustedTypesFor) == 0 { + p.RequireTrustedTypesFor = []string{"script"} + } + + return p +} + // hostsToSources lifts a slice of host strings to SourceExpr values // using Host(). Empty / whitespace-only entries are skipped so callers // can freely concatenate optional lists. diff --git a/packages/go/middleware/csp/preset_test.go b/packages/go/middleware/csp/preset_test.go index 65d62a61..deae3766 100644 --- a/packages/go/middleware/csp/preset_test.go +++ b/packages/go/middleware/csp/preset_test.go @@ -207,6 +207,79 @@ func TestAdminPolicy_IncludeStrictDynamicTrue(t *testing.T) { } } +// TestAdminStrictPolicy_MatchesBaseline pins the admin-strict preset to +// the canonical shape required by issue #59. The shape is: +// +// default-src 'self'; script-src 'self' 'strict-dynamic' [nonce]; +// require-trusted-types-for 'script'; +// trusted-types gn-admin gn-editor 'allow-duplicates'; … +// +// All other directives mirror AdminPolicy. +func TestAdminStrictPolicy_MatchesBaseline(t *testing.T) { + p := AdminStrictPolicy(PolicyOptions{ + ReportURI: "/_/csp-report", + }) + got := p.String() + + for _, m := range []string{ + "default-src 'self'", + "script-src 'self' 'strict-dynamic'", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + "require-trusted-types-for 'script'", + "trusted-types gn-admin gn-editor 'allow-duplicates'", + "report-uri /_/csp-report", + } { + if !strings.Contains(got, m) { + t.Errorf("output missing %q\nfull: %s", m, got) + } + } + + // Admin-strict MUST NOT include unsafe-inline / unsafe-eval. + for _, banned := range []string{"'unsafe-inline'", "'unsafe-eval'"} { + if strings.Contains(got, banned) { + t.Errorf("admin-strict preset leaked %s: %s", banned, got) + } + } +} + +// TestAdminStrictPolicy_AppliesNonceTransitively verifies WithNonce +// folds the per-request nonce into script-src alongside 'strict-dynamic'. +// The nonce-from-context shape is what the Next.js middleware mirrors +// in apps/admin/middleware.ts. +func TestAdminStrictPolicy_AppliesNonceTransitively(t *testing.T) { + p := AdminStrictPolicy(PolicyOptions{}) + got := p.WithNonce("ABC123").String() + if !strings.Contains(got, "'nonce-ABC123'") { + t.Errorf("nonce missing: %s", got) + } + if !strings.Contains(got, "'strict-dynamic'") { + t.Errorf("strict-dynamic missing: %s", got) + } +} + +// TestAdminStrictPolicy_HonorsCallerOverrides verifies the caller can +// substitute their own TrustedTypePolicies (e.g. a hardened deployment +// that disallows 'allow-duplicates') and disable strict-dynamic. +func TestAdminStrictPolicy_HonorsCallerOverrides(t *testing.T) { + f := false + p := AdminStrictPolicy(PolicyOptions{ + TrustedTypePolicies: []string{"gn-admin"}, + IncludeStrictDynamic: &f, + }) + got := p.String() + if !strings.Contains(got, "trusted-types gn-admin") { + t.Errorf("override missed: %s", got) + } + if strings.Contains(got, "gn-editor") { + t.Errorf("caller override should have dropped gn-editor: %s", got) + } + if strings.Contains(got, "'strict-dynamic'") { + t.Errorf("caller suppressed strict-dynamic but it leaked: %s", got) + } +} + // TestHostsToSourcesSkipsEmpty verifies the helper drops empty strings // so callers can freely append optional lists. func TestHostsToSourcesSkipsEmpty(t *testing.T) { From 051ea51fa931e060f5ea461b42f651d7b52bfaa0 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Mon, 25 May 2026 23:24:07 +0200 Subject: [PATCH 2/3] feat(admin/security): DOMPurify-backed Trusted Types policies (#90) Adds the `gn-admin` and `gn-editor` Trusted Types policies plus the `setHTML(el, str)` / `setURL(el, attr, str)` helpers in apps/admin/src/lib/trusted-types.ts. Both policies route their input through DOMPurify (admin: strict profile; editor: strict + inline-SVG profile) before minting the TrustedHTML value, so admin code that previously assigned to `innerHTML` directly now satisfies the strict admin CSP `require-trusted-types-for 'script'` directive. A companion `` component wraps the imperative setter for React render-side use. Every prior `dangerouslySetInnerHTML` callsite in the admin (`/search` page, ``) now uses ``; the block inserter's icon path routes through the new `sanitizeBlockIcon` helper in `@gonext/blocks-editor`, which uses the SSR-safe `isomorphic-dompurify` build so SSR + Trusted Types are both honoured. Tests: 18 specs for `apps/admin/src/lib/trusted-types.ts` covering policy registration, idempotency, SSR fallback, script-vector sanitization, URL pseudo-scheme rejection, and the React escape hatch. 8 specs for `packages/ts/blocks-editor/src/trusted-types.ts` covering icon sanitization (SVG preservation, foreignObject removal, onerror stripping). All 68 existing admin suites (468 tests) and 24 blocks-editor suites (236 tests) remain green. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- apps/admin/package.json | 1 + .../src/app/(authenticated)/search/page.tsx | 18 +- apps/admin/src/components/GlobalSearch.tsx | 17 +- apps/admin/src/components/SafeHTML.tsx | 72 +++ apps/admin/src/lib/trusted-types.test.ts | 188 ++++++++ apps/admin/src/lib/trusted-types.ts | 450 ++++++++++++++++++ packages/ts/blocks-editor/package.json | 3 +- .../ts/blocks-editor/src/block-inserter.tsx | 22 +- .../blocks-editor/src/trusted-types.test.ts | 91 ++++ .../ts/blocks-editor/src/trusted-types.ts | 137 ++++++ pnpm-lock.yaml | 22 +- 11 files changed, 992 insertions(+), 29 deletions(-) create mode 100644 apps/admin/src/components/SafeHTML.tsx create mode 100644 apps/admin/src/lib/trusted-types.test.ts create mode 100644 apps/admin/src/lib/trusted-types.ts create mode 100644 packages/ts/blocks-editor/src/trusted-types.test.ts create mode 100644 packages/ts/blocks-editor/src/trusted-types.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index 346677e3..6df6e24e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.1.6", "lucide-react": "^0.469.0", "next": "^15.0.0", "react": "^19.0.0", diff --git a/apps/admin/src/app/(authenticated)/search/page.tsx b/apps/admin/src/app/(authenticated)/search/page.tsx index 58cc133d..f581a67e 100644 --- a/apps/admin/src/app/(authenticated)/search/page.tsx +++ b/apps/admin/src/app/(authenticated)/search/page.tsx @@ -29,6 +29,7 @@ import { Search as SearchIcon, Timer } from 'lucide-react'; import { api, ApiError } from '@/lib/api-client'; import { Headline } from '@/components/ui/headline'; import { Badge } from '@/components/ui/badge'; +import { SafeHTML } from '@/components/SafeHTML'; import type { SearchHit } from '@/components/GlobalSearch'; interface SearchResponse { @@ -198,14 +199,17 @@ function SearchPageBody(): ReactElement { {hit.title} {hit.excerpt_html && ( -

rule (globals.css → emerald-soft + // on emerald-ink) repaints the highlights with no + // additional code here. + rule (globals.css → emerald-soft on - // emerald-ink) repaints the highlights with no - // additional code here. - dangerouslySetInnerHTML={{ __html: hit.excerpt_html }} + html={hit.excerpt_html} /> )} diff --git a/apps/admin/src/components/GlobalSearch.tsx b/apps/admin/src/components/GlobalSearch.tsx index 2640f833..0c5194c2 100644 --- a/apps/admin/src/components/GlobalSearch.tsx +++ b/apps/admin/src/components/GlobalSearch.tsx @@ -30,6 +30,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Search as SearchIcon } from 'lucide-react'; import { api, ApiError } from '@/lib/api-client'; +import { SafeHTML } from '@/components/SafeHTML'; // DEBOUNCE_MS is the input-to-fetch delay. Tuned for fast typists: // 200 ms is short enough that the dropdown feels live, long enough @@ -306,14 +307,16 @@ export function GlobalSearch( {hit.type} {hit.title} {hit.excerpt_html && ( - + // tags pass through, everything else is + // HTML-escaped. additionally routes the + // string through the gn-admin Trusted Types policy + // (DOMPurify), defense-in-depth for #59/#90. + tags pass through, everything else - // is HTML-escaped. See that file's safety - // contract. - dangerouslySetInnerHTML={{ __html: hit.excerpt_html }} + html={hit.excerpt_html} /> )} diff --git a/apps/admin/src/components/SafeHTML.tsx b/apps/admin/src/components/SafeHTML.tsx new file mode 100644 index 00000000..4397b21a --- /dev/null +++ b/apps/admin/src/components/SafeHTML.tsx @@ -0,0 +1,72 @@ +/** + * `` — the only sanctioned React component for rendering + * server-supplied HTML strings in the admin (issues #59, #90). + * + * Why a dedicated component: + * - The admin's CSP forces `require-trusted-types-for 'script'`, so + * any direct `innerHTML` assignment THROWS unless the value was + * minted by a registered Trusted Types policy. + * - `dangerouslySetInnerHTML` is BANNED across `apps/admin/src/` + * by ESLint (see .eslintrc.json). `` is the + * allowlisted alternative. + * + * The component renders an empty placeholder element on the SSR pass + * and then runs `setHTML(ref.current, html)` once mounted, which: + * 1. routes `html` through DOMPurify's strict admin profile + * 2. funnels the cleaned string through the `gn-admin` (or + * `gn-editor`) Trusted Types policy + * 3. assigns the resulting TrustedHTML to `innerHTML` + * + * The brief flash of empty content during hydration is acceptable for + * the small set of admin surfaces that use this (search excerpts, + * comment moderation previews) and is the safest cross-version pattern + * — React 19 + Trusted Types interop is still a moving target. + * + * Usage: + * + * + * + */ +'use client'; + +import React, { useEffect, useRef, type ElementType, type HTMLAttributes } from 'react'; +import { setHTML, type PolicySurface } from '@/lib/trusted-types'; + +/** + * Props for ``. + * + * - `html` The (potentially-untrusted) string to render. Routed + * through DOMPurify + the named Trusted Types policy. + * - `as` Tag name to render. Defaults to `` so callers + * rendering inside paragraph or button context don't + * introduce block-level boxes. + * - `surface` Selects `gn-admin` (default) vs `gn-editor`. Use + * `editor` when rendering block icons or other rich + * editor content (allows inline SVG). + * - rest Standard HTML props (className, id, role, etc.). + */ +export interface SafeHTMLProps extends HTMLAttributes { + html: string; + as?: ElementType; + surface?: PolicySurface; +} + +export function SafeHTML({ + html, + as, + surface = 'admin', + ...rest +}: SafeHTMLProps): React.ReactElement { + const ref = useRef(null); + + useEffect(() => { + // setHTML internally sanitizes (DOMPurify) and routes through the + // Trusted Types policy. Safe to call on every render; the helper + // is idempotent in the no-change case (we still re-sanitize, but + // the input is small enough that the cost is negligible). + setHTML(ref.current, html, surface); + }, [html, surface]); + + const Tag = (as ?? 'span') as ElementType; + return ; +} diff --git a/apps/admin/src/lib/trusted-types.test.ts b/apps/admin/src/lib/trusted-types.test.ts new file mode 100644 index 00000000..1dfdccd5 --- /dev/null +++ b/apps/admin/src/lib/trusted-types.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for the admin Trusted Types policies + DOM helpers + * (apps/admin/src/lib/trusted-types.ts). + * + * Coverage focus: + * + * - `installAdminPolicy` / `installEditorPolicy` register the spec-shaped + * policy when `window.trustedTypes.createPolicy` is available; both are + * idempotent. + * - In SSR / jsdom (no `trustedTypes` global) the helpers fall back to a + * shim that STILL sanitizes via DOMPurify — the security guarantee is + * preserved even before the browser policy lands. + * - `setHTML` writes sanitized output into `el.innerHTML`. A + * `'); + expect(host.innerHTML).not.toContain(' { + // Should not throw. + expect(() => setHTML(null, '

x

')).not.toThrow(); + }); + + it('uses the editor surface when surface="editor" is passed', () => { + // The editor profile permits inline SVG; the admin profile does NOT + // permit