diff --git a/packages/blocks-admin/src/admin/cors.ts b/packages/blocks-admin/src/admin/cors.ts index c1de1bef..e5da9f7b 100644 --- a/packages/blocks-admin/src/admin/cors.ts +++ b/packages/blocks-admin/src/admin/cors.ts @@ -6,6 +6,24 @@ const ADMIN_ORIGINS = new Set([ "https://deco.chat", "https://admin.decocms.com", "https://decocms.com", + // Studio runs on decocms.com SUBDOMAINS — studio.decocms.com (prod), + // pr-.pr.studio.decocms.com (PR previews), *.local.studio.decocms.com / + // *.preview-studio.decocms.com (sandbox/preview), and the native desktop + // shell in dev (local.studio.decocms.com:4420). The host wildcard matches + // subdomains at any depth; the ":*" port wildcard is REQUIRED because a + // portless CSP host-source only matches the scheme's default port (443) — + // it would miss the native dev origin's :4420. Together they let the Studio + // preview iframes (section gallery, global-section preview) frame + // /deco/render. Does NOT match the apex, so `https://decocms.com` stays. + "https://*.decocms.com:*", + // Local dev + packaged native shell: Studio (localhost:4000 web dev, + // localhost:43120 packaged native) framing a cross-origin sandbox/preview + // render. getAdminOrigins() is always non-empty here, so buildRenderCSP's + // DEFAULT_ADMIN_ORIGINS localhost fallback never applies to /deco/render — + // localhost must be listed explicitly (with ":*", same default-port reason) + // for the dev/native preview iframe to load. + "http://localhost:*", + "https://localhost:*", ]); /** @@ -25,6 +43,15 @@ export function registerAdminOrigins(origins: string[]): void { } } +/** + * The registered admin origins, as an array — for building a + * `frame-ancestors` allowlist (e.g. the `/deco/render` CSP). Reflects any + * origins added via `registerAdminOrigin(s)`. + */ +export function getAdminOrigins(): string[] { + return [...ADMIN_ORIGINS]; +} + export function isAdminOrLocalhost(request: Request): boolean { const origin = request.headers.get("origin") || request.headers.get("referer") || ""; diff --git a/packages/blocks-admin/src/admin/render.csp.test.ts b/packages/blocks-admin/src/admin/render.csp.test.ts new file mode 100644 index 00000000..cf010748 --- /dev/null +++ b/packages/blocks-admin/src/admin/render.csp.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node + +import { registerSection, setBlocks } from "@decocms/blocks/cms"; +import { createElement } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { handleRender } from "./render"; + +// A section that renders a caller-controlled prop straight into an HTML sink — +// i.e. exactly the reflected-XSS shape /deco/render exposes (marquee text, +// title-box rich text, …). The CSP is what must neutralize it. +const XSS_SINK = "site/sections/XssSink.tsx"; +const PAYLOAD = ``; + +beforeEach(() => { + setBlocks({}); + registerSection(XSS_SINK, async () => ({ + default: ({ html }: { html?: string }) => + createElement("div", { + dangerouslySetInnerHTML: { __html: html ?? "" }, + }), + })); +}); + +async function renderPayload(): Promise<{ response: Response; html: string }> { + const props = encodeURIComponent(JSON.stringify({ html: PAYLOAD })); + const response = await handleRender( + new Request(`http://localhost/live/previews/${encodeURIComponent(XSS_SINK)}?props=${props}`), + ); + return { response, html: await response.text() }; +} + +function scriptSrcOf(csp: string): string { + const part = csp + .split(";") + .map((d) => d.trim()) + .find((d) => d.startsWith("script-src")); + return part ?? ""; +} + +describe("handleRender CSP hardening", () => { + it("stamps a nonce-based Content-Security-Policy with no unsafe-inline script", async () => { + const { response } = await renderPayload(); + const csp = response.headers.get("content-security-policy") ?? ""; + + expect(csp).toContain("default-src 'none'"); + const scriptSrc = scriptSrcOf(csp); + expect(scriptSrc).toMatch(/'nonce-[^']+'/); + expect(scriptSrc).not.toContain("'unsafe-inline'"); + expect(csp).toContain("frame-ancestors"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it("allows the Studio admin origins to frame the preview", async () => { + // The default admin-origin registry must let Studio embed /deco/render, or + // the section gallery / global-section preview iframes go blank under the + // new frame-ancestors policy. Studio serves from decocms.com subdomains + // (studio., pr-.pr.studio., *.local.studio., native dev on :4420, …) — + // covered by the host+port wildcard — plus localhost in dev/native. The + // ":*" is load-bearing: a portless host-source only matches port 443. + const { response } = await renderPayload(); + const csp = response.headers.get("content-security-policy") ?? ""; + const frameAncestors = + csp + .split(";") + .map((d) => d.trim()) + .find((d) => d.startsWith("frame-ancestors")) ?? ""; + + expect(frameAncestors).toContain("https://*.decocms.com:*"); + expect(frameAncestors).toContain("localhost:*"); + }); + + it("reflects the payload (proving the sink) but the CSP renders it inert", async () => { + const { response, html } = await renderPayload(); + // The section really did write the attacker HTML into the document… + expect(html).toContain("onerror="); + // …but the response carries the policy that stops that handler from firing. + expect(response.headers.get("content-security-policy")).toContain("script-src 'nonce-"); + }); + + it("tags the framework's own inline script with the same nonce it authorizes", async () => { + const { response, html } = await renderPayload(); + const csp = response.headers.get("content-security-policy") ?? ""; + const nonce = scriptSrcOf(csp).match(/'nonce-([^']+)'/)?.[1]; + + expect(nonce).toBeTruthy(); + // The LIVE_CONTROLS_SCRIPT ` : ""; + const nonceAttr = options.nonce ? ` nonce="${options.nonce}"` : ""; + const scriptTag = options.script ? `${options.script}` : ""; - const bodyContent = options.body ?? `
+ const bodyContent = + options.body ?? + `
Loading preview...
`; diff --git a/packages/blocks/src/sdk/csp.test.ts b/packages/blocks/src/sdk/csp.test.ts new file mode 100644 index 00000000..97a28fb1 --- /dev/null +++ b/packages/blocks/src/sdk/csp.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { buildCSPHeaderValue, buildRenderCSP, generateCSPNonce } from "./csp"; + +describe("buildRenderCSP", () => { + const csp = buildRenderCSP({ nonce: "TESTNONCE123" }); + const directives = Object.fromEntries( + csp.split(";").map((d) => { + const [name, ...rest] = d.trim().split(/\s+/); + return [name, rest.join(" ")]; + }), + ); + + it("locks the default source down to nothing", () => { + expect(directives["default-src"]).toBe("'none'"); + }); + + it("authorizes scripts by nonce and NOT by unsafe-inline", () => { + // The whole point: a nonce authorizes only the framework's own + //