From 784c5cb1ab7397110362e74f4632151ae8bab8fc Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 9 Mar 2026 21:16:42 +0000 Subject: [PATCH] Fix quick edit iframe constraints (#12823) --- .changeset/breezy-tools-lose.md | 7 +++ .changeset/tough-states-sip.md | 12 ++++ .../quick-edit/editor-files/workbench.html | 6 ++ packages/quick-edit/editor-files/workbench.ts | 57 ++++++++++++++++++- packages/quick-edit/src/index.ts | 41 +++++++++++++ .../src/QuickEditor/VSCodeEditor.tsx | 4 +- 6 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 .changeset/breezy-tools-lose.md create mode 100644 .changeset/tough-states-sip.md diff --git a/.changeset/breezy-tools-lose.md b/.changeset/breezy-tools-lose.md new file mode 100644 index 000000000000..d4794514fd79 --- /dev/null +++ b/.changeset/breezy-tools-lose.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/workers-playground": patch +--- + +The workers playground's VSCodeEditor's `postMessage` `targetOrigin` is updated from `'\*'` to the specific `quickEditHost`. + +This prevents the workers-playground from accidentally posting to an incorrect iframe. diff --git a/.changeset/tough-states-sip.md b/.changeset/tough-states-sip.md new file mode 100644 index 000000000000..c51a6a1737ec --- /dev/null +++ b/.changeset/tough-states-sip.md @@ -0,0 +1,12 @@ +--- +"@cloudflare/quick-edit": patch +--- + +Add frame-ancestors CSP and postMessage origin validation to quick-edit + +Mitigate `postMessage` origin bypass: + +- Add Content-Security-Policy frame-ancestors header to quick-edit Worker responses, restricting which origins can embed the editor iframe +- Add client-side origin validation to the window.onmessage handler in workbench.ts, rejecting PORT messages from untrusted origins +- Inject allowed parent origins from server into HTML for client-side use +- Localhost origins are conditionally included when running via wrangler dev diff --git a/packages/quick-edit/editor-files/workbench.html b/packages/quick-edit/editor-files/workbench.html index 31516c7caea8..0351909c57bc 100644 --- a/packages/quick-edit/editor-files/workbench.html +++ b/packages/quick-edit/editor-files/workbench.html @@ -24,6 +24,12 @@ data-settings="{{WORKBENCH_WEB_CONFIGURATION}}" /> + + + typeof v === "string") + : []; + } catch { + return []; + } +} + +/** + * Check whether the given origin matches any of the allowed origins. + * Supports two wildcard forms: + * - "https://*.example.com" — matches any subdomain + * - "http://localhost:*" — matches any port on localhost + */ +function isAllowedOrigin(origin: string, allowedOrigins: string[]): boolean { + return allowedOrigins.some((allowed) => { + // Port wildcard: "http://localhost:*" matches "http://localhost:5173" + if (allowed.endsWith(":*")) { + const prefix = allowed.slice(0, -1); // e.g. "http://localhost:" + return origin.startsWith(prefix); + } + // Subdomain wildcard: "https://*.example.com" matches "https://foo.example.com" + const wildcardIndex = allowed.indexOf("*."); + if (wildcardIndex !== -1) { + const prefix = allowed.slice(0, wildcardIndex); + const suffix = allowed.slice(wildcardIndex + 1); // e.g. ".example.com" + return origin.startsWith(prefix) && origin.endsWith(suffix); + } + return origin === allowed; + }); +} + +/** + * The web page that embeds this VSCode instance must provide a MessagePort + * used for file communication. Only messages from allowed parent origins + * are accepted. */ window.onmessage = (e) => { if (e.data === "PORT" && e.ports[0]) { + const allowedOrigins = getAllowedParentOrigins(); + + if (!isAllowedOrigin(e.origin, allowedOrigins)) { + console.error(`Rejected postMessage from untrusted origin: ${e.origin}`); + return; + } + createEditor(e.ports[0]); } }; diff --git a/packages/quick-edit/src/index.ts b/packages/quick-edit/src/index.ts index a6262e4eb5c3..3b6b40765b6b 100644 --- a/packages/quick-edit/src/index.ts +++ b/packages/quick-edit/src/index.ts @@ -1,3 +1,25 @@ +// Origins that are allowed to embed quick-edit in an iframe and +// communicate with it via postMessage. +// This list is used for both the Content-Security-Policy frame-ancestors +// directive (server-side) and postMessage origin validation (client-side). +const ALLOWED_PARENT_ORIGINS = [ + "https://dash.cloudflare.com", + "https://workers.cloudflare.com", + "https://workers-playground.pages.dev", +]; + +// Origin patterns using wildcards, for Pages preview deployments etc. +// Supported in CSP frame-ancestors and matched manually in the client. +const ALLOWED_PARENT_ORIGIN_WILDCARDS = [ + "https://*.workers-playground.pages.dev", +]; + +// During local development (wrangler dev), the playground runs on localhost. +// We detect this from the request URL and add localhost origins dynamically. +// The wildcard port "http://localhost:*" allows any port in CSP frame-ancestors, +// and "http://localhost" is matched as a prefix in the client-side origin check. +const LOCALHOST_ORIGIN_WILDCARDS = ["http://localhost:*"]; + export default { async fetch(request: Request, env: Env) { const url = new URL(request.url); @@ -12,7 +34,21 @@ export default { forwardedHost?.endsWith(".devprod.cloudflare.dev") ?? false; const authority = isValidForwardedHost ? forwardedHost : url.host; + const isLocalDev = url.hostname === "localhost"; + + const allOrigins = [...ALLOWED_PARENT_ORIGINS]; + const allWildcards = [...ALLOWED_PARENT_ORIGIN_WILDCARDS]; + if (isLocalDev) { + allWildcards.push(...LOCALHOST_ORIGIN_WILDCARDS); + } + const configValues = { + // Allowed parent origins are injected into the HTML so the client-side + // postMessage handler can validate message origins. + ALLOWED_PARENT_ORIGINS: JSON.stringify([ + ...allOrigins, + ...allWildcards, + ]).replace(/"/g, """), WORKBENCH_WEB_CONFIGURATION: JSON.stringify({ configurationDefaults: { "workbench.colorTheme": @@ -76,9 +112,14 @@ export default { (_, key) => configValues[key as keyof typeof configValues] ?? "undefined" ); + // Build the frame-ancestors CSP directive to prevent embedding by + // untrusted origins. + const frameAncestors = [...allOrigins, ...allWildcards].join(" "); + return new Response(replacedWorkbenchText, { headers: { "Content-Type": "text/html", + "Content-Security-Policy": `frame-ancestors ${frameAncestors}`, }, }); }, diff --git a/packages/workers-playground/src/QuickEditor/VSCodeEditor.tsx b/packages/workers-playground/src/QuickEditor/VSCodeEditor.tsx index 68a9c7ec3354..db8c5599f040 100644 --- a/packages/workers-playground/src/QuickEditor/VSCodeEditor.tsx +++ b/packages/workers-playground/src/QuickEditor/VSCodeEditor.tsx @@ -80,7 +80,9 @@ export function VSCodeEditor({ content, onChange }: Props) { if (editorRef !== null) { setLoading(false); - editorRef.contentWindow?.postMessage("PORT", "*", [channel.remote]); + editorRef.contentWindow?.postMessage("PORT", quickEditHost, [ + channel.remote, + ]); } } editorRef.addEventListener("load", handleLoad);