Secure CMS embeds and browser content policies#585
Open
kelvinkipruto wants to merge 2 commits into
Open
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-updatesglobal): newformUrlfield takes the https URL of the Airtable shared form. The legacyembedCodefield remains, but only its iframesrcURL 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.signupUrlfield takes the Mailchimp form action URL; the site renders its own first-party signup form (including Mailchimp's honeypot field). The legacyembedCoderemains as a fallback only.URLobject instead of regex-replacing HTML.Sanitization of remaining HTML
Legacy newsletter embed HTML is sanitized server-side with
sanitize-htmlbefore reachingdangerouslySetInnerHTML: scripts/styles/iframes stripped, allon*event handlers stripped (attribute allowlist), onlyhttps:/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 viaallow, and setsreferrerpolicy="strict-origin-when-cross-origin". The dialog component refuses to render any non-allowlistedsrc(defense in depth).CSP and baseline security headers
headers()innext.config.mjsnow applies a CSP built from the same allowlist (frame-src/form-actiononly include approved providers), plusX-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
UpdateDialogis now a real MUIDialogwitharia-labelledbypointing at a visibleDialogTitle, 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 (labelledrole="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.mddocuments the allowlists, sandbox capabilities and rationale, CSP directives, and how to approve a new provider.Migration notes
embedCodevalues keep working: the Airtable iframe snippet's src is extracted and validated; Mailchimp HTML is sanitized. Editors should move to the newformUrl/signupUrlfields (marked "Preferred" in the admin UI).Verification
pnpm exec tsc --noEmit— clean (the 3 pre-existingShare.tsxerrors also exist onmain).pnpm lint— no errors in changed files.🤖 Generated with Claude Code