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);