diff --git a/apps/admin/.eslintrc.json b/apps/admin/.eslintrc.json index b31e0f93..2c83d2e1 100644 --- a/apps/admin/.eslintrc.json +++ b/apps/admin/.eslintrc.json @@ -1,4 +1,7 @@ { "extends": "next/core-web-vitals", - "ignorePatterns": [".next", "node_modules", "coverage", "dist"] + "ignorePatterns": [".next", "node_modules", "coverage", "dist"], + "rules": { + "react/no-danger": "error" + } } diff --git a/apps/admin/middleware.ts b/apps/admin/middleware.ts index 4882b757..32d23489 100644 --- a/apps/admin/middleware.ts +++ b/apps/admin/middleware.ts @@ -40,16 +40,48 @@ import { NextResponse, type NextRequest } from 'next/server'; /** - * Names of the Trusted Types policies the admin allows. Mirror this - * list with the Go middleware's `Options.RequireTrustedTypes` so the - * dev path (Next.js alone) and prod path (reverse proxy) emit - * identical headers. + * Names of the Trusted Types policies the admin allows. MUST mirror the + * `AdminStrictPolicy` preset in + * `packages/go/middleware/csp/preset.go::AdminStrictPolicy` so the dev + * path (Next.js alone) and prod path (Go reverse proxy) emit IDENTICAL + * headers — drift between the two is silently catastrophic, because + * every assignment to a DOM sink THROWS once the directive is in + * force. + * + * Policy names emitted (issue #59): + * + * - `gn-admin` — the admin chrome's setHTML/setURL helpers + * in `apps/admin/src/lib/trusted-types.ts`. Used by the sidebar + * search, comment moderation previews, and any other surface that + * displays server-supplied HTML. + * + * - `gn-editor` — the block editor's icon and rich-content + * sinks in `packages/ts/blocks-editor/src/trusted-types.ts`. + * + * - `'allow-duplicates'` — the CSP keyword that permits the same + * policy name to be registered twice without throwing. Required in + * dev because Next.js Fast Refresh re-evaluates modules, and in + * prod because the admin host and a plugin frontend may both + * attempt to install `gn-editor` independently. + * + * Intentionally NOT listed: + * + * - `nextjs#bundler` / `default` — the Next.js bundler's per-request + * nonce + `'strict-dynamic'` chain (see `script-src` below) is the + * modern strict pattern recommended by CSP3 §6.1. Listing a bundler + * policy would defeat the point of strict-dynamic. + * + * - `gn-plugin` — plugin-contributed JS loads through + * `@gonext/plugin-frontend-host` on a separate surface that owns + * its own CSP; the admin's strict policy intentionally rejects + * plugin-minted HTML. Plugins that need to render rich content in + * the admin must round-trip through the `gn-editor` policy + * (DOMPurify-sanitized) instead. */ const TRUSTED_TYPES_POLICIES = [ - 'default', - 'nextjs#bundler', // Next.js's bundler emits DOM through this policy - 'dompurify', // sanitization for first-party admin code - 'gn-plugin', // plugin-contributed JS — see @gonext/plugin-frontend-host + 'gn-admin', + 'gn-editor', + "'allow-duplicates'", ] as const; /** @@ -84,12 +116,13 @@ function generateNonce(): string { } /** - * Builds the canonical admin CSP string with the supplied per-request - * nonce folded into script-src and style-src. The output mirrors the - * AdminPolicy preset in packages/go/middleware/csp/preset.go: + * Builds the strict admin CSP string with the supplied per-request + * nonce folded into script-src and style-src (issue #59). The output + * mirrors the AdminStrictPolicy preset in + * packages/go/middleware/csp/preset.go: * * default-src 'self' - * script-src 'self' 'nonce-…' + * script-src 'self' 'nonce-…' 'strict-dynamic' * style-src 'self' 'nonce-…' * img-src 'self' data: blob: * font-src 'self' data: @@ -104,13 +137,27 @@ function generateNonce(): string { * manifest-src 'self' * upgrade-insecure-requests * require-trusted-types-for 'script' - * trusted-types default nextjs#bundler dompurify gn-plugin + * trusted-types gn-admin gn-editor 'allow-duplicates' + * + * Why `'strict-dynamic'` — once the per-request nonce authorizes the + * Next.js root bundle, `'strict-dynamic'` extends that trust transitively + * to every script the bundle loads (App Router chunks, async boundaries, + * etc.) without us having to enumerate them. This closes the + * host-allowlist bypass class that older CSPs were vulnerable to. + * + * Why `'allow-duplicates'` in trusted-types — dev's Fast-Refresh reloads + * re-execute module-init code, which re-runs `installAdminPolicy()`. + * Without `'allow-duplicates'` the browser would throw on the second + * registration. In prod the keyword is harmless: each policy is still + * functionally identical to the first. */ function buildCSP(nonce: string): string { const nonceSource = `'nonce-${nonce}'`; const directives: Array<[string, string[]] | string> = [ ['default-src', ["'self'"]], - ['script-src', ["'self'", nonceSource]], + // 'strict-dynamic' transitively trusts scripts loaded by the nonced + // root bundle — see issue #59 acceptance criteria. + ['script-src', ["'self'", nonceSource, "'strict-dynamic'"]], ['style-src', ["'self'", nonceSource]], ['img-src', ["'self'", 'data:', 'blob:']], ['font-src', ["'self'", 'data:']], 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