Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/blocks-admin/src/admin/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>.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:*",
]);

/**
Expand All @@ -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") || "";

Expand Down
110 changes: 110 additions & 0 deletions packages/blocks-admin/src/admin/render.csp.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<img src=x onerror="alert(document.domain)">`;

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-<n>.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 <script> must carry the nonce, or the preview's
// own editor bridge would be blocked by the same policy.
expect(html).toContain(`<script nonce="${nonce}">`);
expect(html).toContain("editor::inject");
});

it("uses a fresh nonce per response (no reuse across requests)", async () => {
const a = await renderPayload();
const b = await renderPayload();
const nonceOf = (r: Response) =>
scriptSrcOf(r.headers.get("content-security-policy") ?? "").match(/'nonce-([^']+)'/)?.[1];
expect(nonceOf(a.response)).toBeTruthy();
expect(nonceOf(a.response)).not.toBe(nonceOf(b.response));
});

it("applies the CSP to the error path too", async () => {
// Force resolvePreviewRequest deeper paths to still carry the header:
// an unknown component returns HTML via the same htmlResponse helper.
const response = await handleRender(
new Request("http://localhost/live/previews/site%2Fsections%2FDoesNotExist.tsx"),
);
expect(response.headers.get("content-security-policy")).toContain("default-src 'none'");
expect(response.headers.get("content-type")).toContain("text/html");
});
});
61 changes: 43 additions & 18 deletions packages/blocks-admin/src/admin/render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getSection, type ResolvedSection } from "@decocms/blocks/cms";
import { buildRenderCSP, generateCSPNonce } from "@decocms/blocks/sdk/csp";
import { createElement } from "react";
import { buildHtmlShell } from "../sdk/htmlShell";
import { getAdminOrigins } from "./cors";
import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
import { resolvePreviewRequest } from "./resolvePreview";
import { getPreviewWrapper } from "./setup";
Expand All @@ -27,8 +29,37 @@ async function getRenderToString() {
return _renderToString;
}

function wrapInHtmlShell(sectionHtml: string): string {
return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
function wrapInHtmlShell(sectionHtml: string, nonce: string): string {
return buildHtmlShell({
body: sectionHtml,
script: LIVE_CONTROLS_SCRIPT,
nonce,
});
}

/**
* Build the preview HTML `Response` with the hardened CSP.
*
* `/deco/render` is an unauthenticated endpoint that reflects fully
* caller-controlled section props into `text/html`, so a rich-text prop
* reaching an HTML sink is reflected XSS. The nonce-based CSP is the
* execution-layer mitigation — it blocks injected inline handlers / scripts
* while allowing the framework's own `nonce`-tagged preview script (see
* `buildRenderCSP`). Applied on EVERY response path so no branch (including
* the error/unknown fallbacks, which interpolate messages) ships without it.
*/
function htmlResponse(html: string, nonce: string, status = 200): Response {
return new Response(html, {
status,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": buildRenderCSP({
nonce,
adminOrigins: getAdminOrigins(),
}),
"X-Content-Type-Options": "nosniff",
},
});
}

/**
Expand Down Expand Up @@ -58,16 +89,18 @@ async function renderResolvedSection(section: ResolvedSection): Promise<string>
* - Per-request decofile override via AsyncLocalStorage
*/
export async function handleRender(request: Request): Promise<Response> {
// One nonce per response, generated before the try so the catch path can
// reuse it. Every return below goes through htmlResponse(), which stamps the
// CSP built from this nonce.
const nonce = generateCSPNonce();
try {
const resolution = await resolvePreviewRequest(request);
if (resolution.type === "unknown") {
const unknownHtml = wrapInHtmlShell(
`<div style="padding:20px;color:red;">Unknown section: ${escapeHtml(resolution.component)}</div>`,
nonce,
);
return new Response(unknownHtml, {
status: 200,
headers: { "Content-Type": "text/html" },
});
return htmlResponse(unknownHtml, nonce);
}

if (resolution.previewType === "page") {
Expand All @@ -80,24 +113,16 @@ export async function handleRender(request: Request): Promise<Response> {
}
}),
);
return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
return htmlResponse(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n"), nonce), nonce);
}

const sectionHtml = await renderResolvedSection(resolution.sections[0]);
return new Response(wrapInHtmlShell(sectionHtml), {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
return htmlResponse(wrapInHtmlShell(sectionHtml, nonce), nonce);
} catch (error) {
const errorHtml = wrapInHtmlShell(
`<div style="padding:20px;color:red;">Render error: ${escapeHtml((error as Error).message)}</div>`,
nonce,
);
return new Response(errorHtml, {
status: 200,
headers: { "Content-Type": "text/html" },
});
return htmlResponse(errorHtml, nonce);
}
}
14 changes: 12 additions & 2 deletions packages/blocks-admin/src/sdk/htmlShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface HtmlShellOptions {
body?: string;
/** Inline <script> content to inject in <head>. */
script?: string;
/**
* Nonce applied to the injected inline `<script>` so it survives a
* `script-src 'nonce-…'` CSP (see `buildRenderCSP` in
* `@decocms/blocks/sdk/csp`). Without it the framework's own preview script
* would be blocked by the same policy that neutralizes injected scripts.
*/
nonce?: string;
}

/**
Expand All @@ -33,9 +40,12 @@ export function buildHtmlShell(options: HtmlShellOptions = {}): string {
.filter(Boolean)
.join("\n ");

const scriptTag = options.script ? `<script>${options.script}</script>` : "";
const nonceAttr = options.nonce ? ` nonce="${options.nonce}"` : "";
const scriptTag = options.script ? `<script${nonceAttr}>${options.script}</script>` : "";

const bodyContent = options.body ?? `<div id="preview-root" style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui;color:#666;">
const bodyContent =
options.body ??
`<div id="preview-root" style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui;color:#666;">
Loading preview...
</div>`;

Expand Down
71 changes: 71 additions & 0 deletions packages/blocks/src/sdk/csp.test.ts
Original file line number Diff line number Diff line change
@@ -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
// <script nonce=…>. Inline event-handler attributes (onerror/onload) are
// never matched by a nonce, so the reflected-XSS payload cannot execute.
expect(directives["script-src"]).toBe("'nonce-TESTNONCE123'");
expect(directives["script-src"]).not.toContain("'unsafe-inline'");
});

it("keeps non-script directives permissive so the preview still paints", () => {
// Inline styles are not a JS-execution vector; images/fonts come from CDNs.
expect(directives["style-src"]).toContain("'unsafe-inline'");
expect(directives["img-src"]).toContain("https:");
expect(directives["font-src"]).toContain("https:");
});

it("blocks base-uri and form-action hijacking", () => {
expect(directives["base-uri"]).toBe("'none'");
expect(directives["form-action"]).toBe("'none'");
});

it("frames only 'self' + admin origins (clickjacking guard)", () => {
expect(directives["frame-ancestors"]).toContain("'self'");
expect(directives["frame-ancestors"]).toContain("https://admin.deco.cx");
});

it("honors a custom admin-origin allowlist", () => {
const custom = buildRenderCSP({
nonce: "n",
adminOrigins: ["https://studio.decocms.com"],
});
expect(custom).toContain("frame-ancestors 'self' https://studio.decocms.com");
expect(custom).not.toContain("https://admin.deco.cx");
});
});

describe("generateCSPNonce", () => {
it("returns a non-empty base64 string", () => {
const nonce = generateCSPNonce();
expect(nonce.length).toBeGreaterThan(0);
expect(nonce).toMatch(/^[A-Za-z0-9+/]+=*$/);
});

it("returns a fresh value each call", () => {
const seen = new Set(Array.from({ length: 100 }, () => generateCSPNonce()));
expect(seen.size).toBe(100);
});
});

describe("buildCSPHeaderValue (unchanged, frame-ancestors only)", () => {
it("still returns only frame-ancestors", () => {
expect(buildCSPHeaderValue()).toMatch(/^frame-ancestors /);
});
});
Loading