Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/breezy-tools-lose.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .changeset/tough-states-sip.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions packages/quick-edit/editor-files/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
data-settings="{{WORKBENCH_WEB_CONFIGURATION}}"
/>

<!-- Allowed parent origins for postMessage validation -->
<meta
id="vscode-allowed-parent-origins"
data-origins="{{ALLOWED_PARENT_ORIGINS}}"
/>

<link
rel="stylesheet"
href="{{WORKBENCH_WEB_BASE_URL}}/out/vs/workbench/workbench.web.main.css"
Expand Down
57 changes: 56 additions & 1 deletion packages/quick-edit/editor-files/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,65 @@ function createEditor(port: MessagePort) {
}

/**
* The web page that embeds this VSCode instance must provide a MessagePort used for file communication
* Read the list of allowed parent origins from the server-injected meta tag.
* These origins are permitted to send the "PORT" postMessage to establish
* the IPC channel. Entries may use a leading wildcard for subdomain matching
* (e.g. "https://*.example.com").
*/
function getAllowedParentOrigins(): string[] {
const el = document.getElementById("vscode-allowed-parent-origins");
const raw = el?.getAttribute("data-origins");
if (!raw) {
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((v) => 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]);
}
};
41 changes: 41 additions & 0 deletions packages/quick-edit/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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, "&quot;"),
WORKBENCH_WEB_CONFIGURATION: JSON.stringify({
configurationDefaults: {
"workbench.colorTheme":
Expand Down Expand Up @@ -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}`,
},
});
},
Expand Down
4 changes: 3 additions & 1 deletion packages/workers-playground/src/QuickEditor/VSCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading