diff --git a/docs/security/embeds-and-csp.md b/docs/security/embeds-and-csp.md new file mode 100644 index 00000000..12d309b0 --- /dev/null +++ b/docs/security/embeds-and-csp.md @@ -0,0 +1,135 @@ +# CMS embeds and browser content policies + +This document describes the trust boundary between CMS-authored content +and the browser: which third-party embeds are allowed, how they are +constrained, and which security headers every response carries. + +The single source of truth for the embed allowlist is +[`src/lib/security/embedPolicy.mjs`](../../src/lib/security/embedPolicy.mjs). +The Content-Security-Policy in +[`src/lib/security/headers.mjs`](../../src/lib/security/headers.mjs) is +built from the same allowlist, so approving a new provider in one place +updates both the runtime validation and the CSP. + +## Structured embed configuration + +CMS editors configure embeds with structured fields, not raw HTML: + +- **Promise updates** (`promise-updates` global): the `formUrl` field + takes the https URL of the Airtable shared form (e.g. + `https://airtable.com/embed/app…/shr…`). The legacy `embedCode` field + still accepts a plain Airtable ``, + ), + ).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"); + }); +});