Skip to content

Secure CMS embeds and browser content policies#585

Open
kelvinkipruto wants to merge 2 commits into
mainfrom
claude/github-issue-575-82e417
Open

Secure CMS embeds and browser content policies#585
kelvinkipruto wants to merge 2 commits into
mainfrom
claude/github-issue-575-82e417

Conversation

@kelvinkipruto

Copy link
Copy Markdown
Contributor

Closes #575 (parent: #572)

What this does

Replaces unrestricted CMS-controlled active content with structured, allowlisted embed configuration, sanitizes the remaining legacy HTML, constrains iframes, adds a tested CSP + baseline security headers, and rebuilds the promise-update experience as an accessible MUI Dialog.

Changes

Structured embed configuration (no raw HTML where avoidable)

  • Promise updates (promise-updates global): new formUrl field takes the https URL of the Airtable shared form. The legacy embedCode field remains, but only its iframe src URL is used — the HTML is never rendered. Both are validated at save time (scripts, inline event handlers, and non-allowlisted origins rejected) and re-validated at render time (resolveUpdateFormUrl). Only a validated URL string ever crosses the server/client boundary.
  • Newsletter (site settings → Engagement): new signupUrl field takes the Mailchimp form action URL; the site renders its own first-party signup form (including Mailchimp's honeypot field). The legacy embedCode remains as a fallback only.
  • The Airtable prefill logic now operates on a URL object instead of regex-replacing HTML.

Sanitization of remaining HTML

Legacy newsletter embed HTML is sanitized server-side with sanitize-html before reaching dangerouslySetInnerHTML: scripts/styles/iframes stripped, all on* event handlers stripped (attribute allowlist), only https:/mailto: schemes allowed, and any <form> whose action is not on an approved newsletter provider is removed entirely.

Allowlisted, constrained iframes

The embed origin allowlist lives in src/lib/security/embedPolicy.mjs — a single source of truth shared by the runtime validation, the Payload field validators, and the CSP. The update-form iframe is sandboxed to the documented minimum (allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox), denies camera/microphone/geolocation/payment/fullscreen via allow, and sets referrerpolicy="strict-origin-when-cross-origin". The dialog component refuses to render any non-allowlisted src (defense in depth).

CSP and baseline security headers

headers() in next.config.mjs now applies a CSP built from the same allowlist (frame-src/form-action only include approved providers), plus X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Permissions-Policy, and HSTS (production only). Directives accommodate MUI/Emotion inline styles, Next.js/Payload Admin inline scripts, Sentry (tunnel + direct ingest), and remote media. Rationale for every directive is documented.

Accessible update dialog

UpdateDialog is now a real MUI Dialog with aria-labelledby pointing at a visible DialogTitle, native focus trapping, Escape handling, focus restoration, and a labelled close button — replacing the previous hidden-Dialog + hand-rolled fixed overlay.

Tests

  • tests/int/embeds.int.spec.ts (47 tests): allowlist acceptance/rejection (http, javascript:, data:, lookalike hosts), field validators, prefill URL building, sanitizer behavior.
  • tests/int/securityHeaders.int.spec.ts (7 tests): CSP directive and header baseline assertions, dev/prod differences.
  • tests/e2e/security.e2e.spec.ts: live response headers; dialog keyboard/screen-reader behavior (labelled role="dialog", focus trap, Escape, focus restoration, labelled close button); sandbox attributes; refusal to render a non-allowlisted embed — via a dev-only harness at /dev/update-dialog (404s in production builds).

Docs

docs/security/embeds-and-csp.md documents the allowlists, sandbox capabilities and rationale, CSP directives, and how to approve a new provider.

Migration notes

  • Existing embedCode values keep working: the Airtable iframe snippet's src is extracted and validated; Mailchimp HTML is sanitized. Editors should move to the new formUrl/signupUrl fields (marked "Preferred" in the admin UI).
  • Both legacy fields are now optional; validation requires at least one of the pair to be set.
  • MongoDB adapter — no migration needed for the new fields.

Verification

  • pnpm exec tsc --noEmit — clean (the 3 pre-existing Share.tsx errors also exist on main).
  • pnpm lint — no errors in changed files.
  • New vitest suites: 54/54 passing.
  • Playwright suite added; not run locally (needs the dev server + DB) — CI should exercise it.

🤖 Generated with Claude Code

kelvinkipruto and others added 2 commits July 16, 2026 14:25
Add sanitize-html and its TypeScript types for server-side HTML sanitization. Create a shared embed allowlist policy that syncs Content-Security-Policy headers with runtime validation to ensure consistency across the app. Update CMS fields to prefer structured URL inputs over raw embed code, with validation for approved third-party providers. Add validation for legacy embed snippets to block unsafe content like scripts, inline event handlers, and iframes. Configure global security headers including strict CSP rules for the Next.js application.
Replace unrestricted CMS-controlled HTML embeds with structured,
allowlisted configuration and add browser security headers (#575):

- Structured embed fields: promise-updates formUrl (Airtable) and
  newsletter signupUrl (Mailchimp), validated at save time against a
  shared origin allowlist (src/lib/security/embedPolicy.mjs)
- Promise-update path no longer renders CMS HTML at all: only a
  validated, allowlisted https URL crosses to the client
- Legacy newsletter embed HTML sanitized server-side with sanitize-html
  (scripts, event handlers, iframes, unsafe URLs, and forms posting to
  unapproved origins are stripped)
- Update dialog rewritten as a real labelled MUI Dialog (focus trap,
  Escape, focus restoration) with a minimal, documented iframe sandbox
- CSP and baseline security headers built from the same allowlist and
  applied via next.config.mjs headers()
- Vitest coverage for allowlist/validator/sanitizer/CSP behavior;
  Playwright coverage for headers, rejected embeds, and keyboard/
  screen-reader modal behavior via a dev-only harness route
- Decisions documented in docs/security/embeds-and-csp.md

Closes #575

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
PromiseTracker Ready Ready Preview, Comment Jul 16, 2026 11:38am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Secure CMS embeds and browser content policies

1 participant