Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/admin/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
75 changes: 61 additions & 14 deletions apps/admin/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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:
Expand All @@ -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:']],
Expand Down
1 change: 1 addition & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 11 additions & 7 deletions apps/admin/src/app/(authenticated)/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -198,14 +199,17 @@ function SearchPageBody(): ReactElement {
<span className="search-page__hit-title">{hit.title}</span>
</Link>
{hit.excerpt_html && (
<p
// ExcerptHTML is server-sanitised by
// packages/go/search/highlight.go AND then routed
// through the gn-admin Trusted Types policy
// (DOMPurify + 'mark'-preserving profile). The
// brand's <mark> rule (globals.css → emerald-soft
// on emerald-ink) repaints the highlights with no
// additional code here.
<SafeHTML
as="p"
className="search-page__hit-excerpt"
// ExcerptHTML is server-sanitised; see
// packages/go/search/highlight.go. The brand's
// <mark> 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}
/>
)}
</li>
Expand Down
17 changes: 10 additions & 7 deletions apps/admin/src/components/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -306,14 +307,16 @@ export function GlobalSearch(
<span className="global-search__hit-type">{hit.type}</span>
<span className="global-search__hit-title">{hit.title}</span>
{hit.excerpt_html && (
<span
// ExcerptHTML is server-sanitised by
// packages/go/search/highlight.go — only <mark>
// tags pass through, everything else is
// HTML-escaped. <SafeHTML> additionally routes the
// string through the gn-admin Trusted Types policy
// (DOMPurify), defense-in-depth for #59/#90.
<SafeHTML
as="span"
className="global-search__hit-excerpt"
// ExcerptHTML is server-sanitised by
// packages/go/search/highlight.go — only
// <mark> tags pass through, everything else
// is HTML-escaped. See that file's safety
// contract.
dangerouslySetInnerHTML={{ __html: hit.excerpt_html }}
html={hit.excerpt_html}
/>
)}
</Link>
Expand Down
72 changes: 72 additions & 0 deletions apps/admin/src/components/SafeHTML.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* `<SafeHTML>` — 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). `<SafeHTML>` 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:
*
* <SafeHTML html={hit.excerpt_html} className="hit-excerpt" />
* <SafeHTML html={blockIconSVG} as="span" surface="editor" />
*/
'use client';

import React, { useEffect, useRef, type ElementType, type HTMLAttributes } from 'react';
import { setHTML, type PolicySurface } from '@/lib/trusted-types';

/**
* Props for `<SafeHTML>`.
*
* - `html` The (potentially-untrusted) string to render. Routed
* through DOMPurify + the named Trusted Types policy.
* - `as` Tag name to render. Defaults to `<span>` 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<HTMLElement> {
html: string;
as?: ElementType;
surface?: PolicySurface;
}

export function SafeHTML({
html,
as,
surface = 'admin',
...rest
}: SafeHTMLProps): React.ReactElement {
const ref = useRef<HTMLElement | null>(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 <Tag ref={ref} {...rest} />;
}
Loading
Loading