From 0282f6f056f03e7c2e4f526131ee1843e71336d5 Mon Sep 17 00:00:00 2001 From: kelvin <43873157+kelvinkipruto@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:25:06 +0300 Subject: [PATCH 1/2] feat(embeds,security): add secure embed handling, sanitization, and CSP 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. --- src/lib/embeds/sanitize.ts | 93 ++++++++++++++ src/lib/embeds/validation.ts | 208 +++++++++++++++++++++++++++++++ src/lib/security/embedPolicy.mjs | 87 +++++++++++++ src/lib/security/headers.mjs | 98 +++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 src/lib/embeds/sanitize.ts create mode 100644 src/lib/embeds/validation.ts create mode 100644 src/lib/security/embedPolicy.mjs create mode 100644 src/lib/security/headers.mjs diff --git a/src/lib/embeds/sanitize.ts b/src/lib/embeds/sanitize.ts new file mode 100644 index 00000000..243d3f98 --- /dev/null +++ b/src/lib/embeds/sanitize.ts @@ -0,0 +1,93 @@ +/** + * Server-side sanitization for legacy CMS-authored newsletter embed + * HTML (Mailchimp signup snippets). New configurations should use the + * structured `signupUrl` field instead; this is the safety net for + * snippets that predate it. + * + * Policy: static markup and signup-form elements only. Scripts, + * styles, iframes, inline event handlers, and non-https URLs are + * stripped, and any
whose action is not on an approved + * newsletter provider (see src/lib/security/embedPolicy.mjs) is + * removed entirely. + */ + +import sanitizeHtml from "sanitize-html"; + +import { isAllowedFormActionUrl } from "@/lib/security/embedPolicy.mjs"; + +const ALLOWED_INPUT_TYPES = new Set([ + "text", + "email", + "submit", + "hidden", + "checkbox", + "radio", +]); + +const sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [ + "div", + "p", + "span", + "strong", + "em", + "small", + "br", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "a", + "form", + "label", + "input", + "button", + ], + allowedAttributes: { + "*": ["class", "id"], + form: ["class", "id", "action", "method", "target", "name", "novalidate"], + input: [ + "class", + "id", + "type", + "name", + "value", + "placeholder", + "required", + "tabindex", + "autocomplete", + "aria-label", + "aria-hidden", + "aria-required", + ], + label: ["class", "id", "for"], + button: ["class", "id", "type", "name"], + a: ["class", "id", "href", "target", "rel"], + }, + allowedSchemes: ["https", "mailto"], + allowProtocolRelative: false, + exclusiveFilter: (frame) => { + if (frame.tag === "form") { + return !isAllowedFormActionUrl(frame.attribs?.action); + } + if (frame.tag === "input") { + const type = (frame.attribs?.type || "text").toLowerCase(); + return !ALLOWED_INPUT_TYPES.has(type); + } + return false; + }, + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { ...attribs, rel: "noopener noreferrer" }, + }), + }, +}; + +export const sanitizeNewsletterEmbedHtml = (html: string): string => + sanitizeHtml(html, sanitizeOptions); diff --git a/src/lib/embeds/validation.ts b/src/lib/embeds/validation.ts new file mode 100644 index 00000000..f6e11dbe --- /dev/null +++ b/src/lib/embeds/validation.ts @@ -0,0 +1,208 @@ +/** + * Validation and URL helpers for CMS-configured embeds. + * + * Kept free of heavy dependencies (no sanitize-html) because the + * Payload field validators below are bundled into the admin client. + * The origin allowlist lives in src/lib/security/embedPolicy.mjs and + * is shared with the Content-Security-Policy. + */ + +import { + IFRAME_EMBED_PROVIDERS, + FORM_ACTION_PROVIDERS, + isAllowedFormActionUrl, + isAllowedIframeUrl, +} from "@/lib/security/embedPolicy.mjs"; + +const describeHosts = (providers: { hosts: string[] }[]) => + providers.flatMap(({ hosts }) => hosts).join(", "); + +const IFRAME_HOSTS_LABEL = describeHosts(IFRAME_EMBED_PROVIDERS); +const FORM_HOSTS_LABEL = describeHosts(FORM_ACTION_PROVIDERS); + +const SCRIPT_TAG_PATTERN = /<\s*script\b/i; +const EVENT_HANDLER_PATTERN = /\son\w+\s*=/i; + +/** Extracts the `src` of the first `, + ), + ).toBe(AIRTABLE_URL); + }); + + it("returns null when there is no iframe src", () => { + expect(extractIframeSrc("
hello
")).toBeNull(); + expect(extractIframeSrc(null)).toBeNull(); + }); +}); + +describe("resolveUpdateFormUrl", () => { + it("prefers the structured formUrl", () => { + expect( + resolveUpdateFormUrl({ + formUrl: AIRTABLE_URL, + embedCode: ``, + }), + ).toBe(AIRTABLE_URL); + }); + + it("falls back to the legacy iframe snippet src", () => { + expect( + resolveUpdateFormUrl({ + embedCode: ``, + }), + ).toBe(AIRTABLE_URL); + }); + + it("rejects URLs outside the allowlist", () => { + expect( + resolveUpdateFormUrl({ formUrl: "https://evil.example.com/embed" }), + ).toBeNull(); + expect( + resolveUpdateFormUrl({ + embedCode: ``, + }), + ).toBeNull(); + expect(resolveUpdateFormUrl(null)).toBeNull(); + }); +}); + +describe("withUpdateFormPrefill", () => { + it("adds prefill query params to the form URL", () => { + const result = new URL( + withUpdateFormPrefill(AIRTABLE_URL, { + promiseTitle: "Build 500km of roads", + promiseUrl: "https://checkmedia.org/promise/1", + updateDate: "2026-07-16T10:00:00Z", + }), + ); + expect(result.searchParams.get("prefill_Promise")).toBe( + "Build 500km of roads", + ); + expect(result.searchParams.get("prefill_CheckMedia Link")).toBe( + "https://checkmedia.org/promise/1", + ); + expect(result.searchParams.get("hide_CheckMedia Link")).toBe("true"); + expect(result.searchParams.get("prefill_Date")).toBe("2026-07-16"); + }); + + it("leaves the URL unchanged when no prefill values are provided", () => { + expect(withUpdateFormPrefill(AIRTABLE_URL, {})).toBe(AIRTABLE_URL); + }); +}); + +describe("getMailchimpBotFieldName", () => { + it("derives the honeypot field name from u and id params", () => { + expect(getMailchimpBotFieldName(MAILCHIMP_URL)).toBe("b_abc123_def456"); + }); + + it("returns null when params are missing", () => { + expect( + getMailchimpBotFieldName("https://example.us1.list-manage.com/post"), + ).toBeNull(); + }); +}); + +describe("promise-update field validators", () => { + it("accepts an approved form URL and empty optional value", () => { + expect(validateUpdateFormUrlField(AIRTABLE_URL)).toBe(true); + expect(validateUpdateFormUrlField("")).toBe(true); + }); + + it("rejects non-allowlisted form URLs", () => { + expect( + validateUpdateFormUrlField("https://evil.example.com/embed"), + ).toMatch(/approved embed provider/); + }); + + it("requires either formUrl or embedCode", () => { + expect(validateUpdateEmbedCodeField("", null)).toMatch(/Provide/); + expect(validateUpdateEmbedCodeField("", AIRTABLE_URL)).toBe(true); + }); + + it("accepts a plain allowlisted iframe snippet", () => { + expect( + validateUpdateEmbedCodeField(``), + ).toBe(true); + }); + + it.each([ + [ + "script tags", + ``, + /script/i, + ], + [ + "inline event handlers", + ``, + /event handler/i, + ], + ["snippets without an iframe", `
nothing
`, /iframe/i], + [ + "non-allowlisted iframe src", + ``, + /approved embed provider/i, + ], + ])("rejects %s", (_label, snippet, message) => { + expect(validateUpdateEmbedCodeField(snippet)).toMatch(message); + }); +}); + +describe("newsletter field validators", () => { + it("accepts an approved signup URL and empty optional value", () => { + expect(validateNewsletterSignupUrlField(MAILCHIMP_URL)).toBe(true); + expect(validateNewsletterSignupUrlField("")).toBe(true); + }); + + it("rejects non-allowlisted signup URLs", () => { + expect( + validateNewsletterSignupUrlField("https://evil.example.com/collect"), + ).toMatch(/approved newsletter provider/); + }); + + it("requires either signupUrl or embedCode", () => { + expect(validateNewsletterEmbedCodeField("", null)).toMatch(/Provide/); + expect(validateNewsletterEmbedCodeField("", MAILCHIMP_URL)).toBe(true); + }); + + it("accepts a plain Mailchimp form snippet", () => { + expect( + validateNewsletterEmbedCodeField( + `
`, + ), + ).toBe(true); + }); + + it.each([ + ["script tags", ``, /script/i], + [ + "inline event handlers", + `
`, + /event handler/i, + ], + ["iframes", ``, /iframe/i], + [ + "forms posting to unapproved origins", + `
`, + /approved newsletter provider/i, + ], + ])("rejects %s", (_label, snippet, message) => { + expect(validateNewsletterEmbedCodeField(snippet)).toMatch(message); + }); +}); + +describe("sanitizeNewsletterEmbedHtml", () => { + it("keeps an approved Mailchimp signup form intact", () => { + const html = `
`; + const result = sanitizeNewsletterEmbedHtml(html); + expect(result).toContain(`action="${MAILCHIMP_URL.replace(/&/g, "&")}"`); + expect(result).toContain('type="email"'); + expect(result).toContain('type="submit"'); + }); + + it("strips script tags and their content", () => { + const result = sanitizeNewsletterEmbedHtml( + `
ok
`, + ); + expect(result).not.toContain("script"); + expect(result).not.toContain("evil.example.com"); + expect(result).toContain("ok"); + }); + + it("strips inline event handlers", () => { + const result = sanitizeNewsletterEmbedHtml( + `
`, + ); + expect(result).not.toMatch(/on\w+=/i); + }); + + it("strips javascript: and non-https URLs", () => { + const result = sanitizeNewsletterEmbedHtml( + `abc`, + ); + expect(result).not.toContain("javascript:"); + expect(result).not.toContain("http://insecure.example.com"); + expect(result).toContain("https://safe.example.com"); + }); + + it("removes forms that post to unapproved origins", () => { + const result = sanitizeNewsletterEmbedHtml( + `
`, + ); + expect(result).not.toContain("form"); + expect(result).not.toContain("attacker.example.com"); + }); + + it("removes iframes, styles, and unsafe input types", () => { + const result = sanitizeNewsletterEmbedHtml( + ``, + ); + expect(result).not.toContain("iframe"); + expect(result).not.toContain("style"); + expect(result).not.toContain("input"); + }); + + it("adds rel=noopener to links", () => { + const result = sanitizeNewsletterEmbedHtml( + `link`, + ); + expect(result).toContain('rel="noopener noreferrer"'); + }); +}); diff --git a/tests/int/securityHeaders.int.spec.ts b/tests/int/securityHeaders.int.spec.ts new file mode 100644 index 00000000..56ce8b84 --- /dev/null +++ b/tests/int/securityHeaders.int.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + buildContentSecurityPolicy, + buildSecurityHeaders, +} from "@/lib/security/headers.mjs"; + +const getDirective = (csp: string, name: string) => + csp + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name} `)); + +describe("buildContentSecurityPolicy", () => { + const prodCsp = buildContentSecurityPolicy({ isDev: false }); + + it("restricts framing to self and approved embed providers only", () => { + expect(getDirective(prodCsp, "frame-src")).toBe( + "frame-src 'self' https://airtable.com", + ); + }); + + it("restricts form submission to self and approved newsletter providers", () => { + expect(getDirective(prodCsp, "form-action")).toBe( + "form-action 'self' https://*.list-manage.com", + ); + }); + + it("locks down baseline directives", () => { + expect(getDirective(prodCsp, "default-src")).toBe("default-src 'self'"); + expect(getDirective(prodCsp, "object-src")).toBe("object-src 'none'"); + expect(getDirective(prodCsp, "base-uri")).toBe("base-uri 'self'"); + expect(getDirective(prodCsp, "frame-ancestors")).toBe( + "frame-ancestors 'self'", + ); + }); + + it("supports MUI/Emotion inline styles and Sentry ingest", () => { + expect(getDirective(prodCsp, "style-src")).toContain("'unsafe-inline'"); + expect(getDirective(prodCsp, "connect-src")).toContain( + "https://*.sentry.io", + ); + }); + + it("only allows eval and websockets in development", () => { + expect(getDirective(prodCsp, "script-src")).not.toContain("'unsafe-eval'"); + expect(getDirective(prodCsp, "connect-src")).not.toContain("ws:"); + + const devCsp = buildContentSecurityPolicy({ isDev: true }); + expect(getDirective(devCsp, "script-src")).toContain("'unsafe-eval'"); + expect(getDirective(devCsp, "connect-src")).toContain("ws:"); + }); +}); + +describe("buildSecurityHeaders", () => { + const prodHeaders = buildSecurityHeaders({ isDev: false }); + const headerMap = new Map(prodHeaders.map(({ key, value }) => [key, value])); + + it("includes the full baseline header set in production", () => { + expect(headerMap.get("Content-Security-Policy")).toBeTruthy(); + expect(headerMap.get("X-Content-Type-Options")).toBe("nosniff"); + expect(headerMap.get("Referrer-Policy")).toBe( + "strict-origin-when-cross-origin", + ); + expect(headerMap.get("X-Frame-Options")).toBe("SAMEORIGIN"); + expect(headerMap.get("Permissions-Policy")).toContain("camera=()"); + expect(headerMap.get("Strict-Transport-Security")).toContain("max-age="); + }); + + it("omits HSTS in development", () => { + const devKeys = buildSecurityHeaders({ isDev: true }).map( + ({ key }) => key, + ); + expect(devKeys).not.toContain("Strict-Transport-Security"); + expect(devKeys).toContain("Content-Security-Policy"); + }); +});