From f7d3db5875ca14f3206633fd8e1218581738013d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 19:25:21 -0700 Subject: [PATCH] fix(embed): make embed-mode framing opt-in Embed-mode documents now keep the server's framing protection unless GEV_EMBED_FRAME_ANCESTORS names the pages that may frame them (or * for any page). The MCP Apps panel loads the app into its own page and needs no framing. View answers are posted to the origin that sent the view rather than to any origin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 4 ++-- build/embed-framing.js | 22 +++++++++++-------- docs/CURRENT-STATE.md | 3 ++- docs/TOOLS.md | 15 +++++++------ src/app/embed.js | 40 ++++++++++++++++++++++------------ src/app/embed.test.mjs | 19 +++++++++++++--- src/tooling/viteBuild.test.mjs | 7 ++++++ 7 files changed, 74 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 7a802927c..071d42d3c 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,6 @@ AISSTREAM_API_KEY= OVERPASS_UPSTREAMS= # Optional: who may frame the app in embed mode (?embed=1), as CSP -# frame-ancestors sources such as "https://example.com". Unset allows any -# page; every other page of the app can never be framed. +# frame-ancestors sources such as "https://example.com", or * for any page. +# Unset, no page may frame it. Other pages of the app can never be framed. # GEV_EMBED_FRAME_ANCESTORS= diff --git a/build/embed-framing.js b/build/embed-framing.js index f3b53d5dc..2a34e115a 100644 --- a/build/embed-framing.js +++ b/build/embed-framing.js @@ -1,11 +1,12 @@ /** - * Lets other pages frame the app in embed mode (`?embed=1`). Every other + * Lets chosen pages frame the app in embed mode (`?embed=1`). Every * document keeps the server's `X-Frame-Options: DENY` and - * `frame-ancestors 'none'`, which protect Provider Settings. Embed mode - * shows only the globe, so for those documents framing is limited to - * `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default. - * "Any" sends no framing restriction at all: `frame-ancestors *` would still - * refuse sandboxed frames with an opaque origin, which panel hosts use. + * `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names who may + * frame embed-mode documents: CSP frame-ancestors sources, or `*` for any + * page. A framing page can change what the app shows, so no page may frame + * it by default. Other documents, including Provider Settings, are never + * framable. "Any" sends no framing restriction at all: `frame-ancestors *` + * would still refuse sandboxed frames with an opaque origin. */ const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']); @@ -26,10 +27,13 @@ export function isEmbedDocumentRequest(url) { /** Vite plugin applying the embed framing policy on dev and preview servers. */ export function embedFramingPlugin({ - ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*', + ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '', } = {}) { - const anywhere = ancestors.trim() === '*'; - const policy = `frame-ancestors ${ancestors}`; + const allowed = ancestors.trim(); + // Unset: embed documents keep the server's framing protection. + if (!allowed) return { name: 'embed-framing' }; + const anywhere = allowed === '*'; + const policy = `frame-ancestors ${allowed}`; const install = (server) => { server.middlewares.use((req, res, next) => { if (!isEmbedDocumentRequest(req.url)) return next(); diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3731726f1..7e1a0cc10 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,7 +10,8 @@ The panel runs the app's panel build (`npm run build:panel`, served at `/panel/`) and loads it, with its data, through the MCP server, so it works in Claude Desktop and in the Codex and ChatGPT desktop apps with a local server and no HTTPS. `?embed=1` shows the app as the globe alone and takes -new views from the page that frames it. MCP leads with the tools that find +new views from the page that frames it; framing is off unless +`GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find what to show. See [tools and the MCP server](TOOLS.md). ## Tools and local MCP server — October 1, 2026 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4ed69db69..1e2397c0c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -164,14 +164,15 @@ The app applies it through its own actions (style, map, exactly the view's layers, annotations, then the followed entity, retried until its layer has it, or the camera when nothing is followed or the entity is not there yet, since a camera flight would end the follow; then cockpit view when asked) and -answers `{ type: 'gev:view-applied', id, ok, steps }`. It -posts `{ type: 'gev:ready' }` once it can take views, and only its parent -page can send them. See `src/app/embed.js`. +answers `{ type: 'gev:view-applied', id, ok, steps }` to the origin that +sent the view. It posts `{ type: 'gev:ready' }` once it can take views, and +only its parent page can send them. See `src/app/embed.js`. -The development and preview servers let other pages frame embed-mode -documents only; every other document keeps `X-Frame-Options: DENY` and -`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages -may frame them (any by default). +No page may frame the app by default: every document keeps +`X-Frame-Options: DENY` and `frame-ancestors 'none'`. Setting +`GEV_EMBED_FRAME_ANCESTORS` lets the pages it names (CSP frame-ancestors +sources, or `*` for any page) frame embed-mode documents only. The MCP Apps +panel loads the app into its own page and needs no framing. Answers that have something to show include `data.view`: the view that shows them, with the matching layers on, an area framed from above, and a diff --git a/src/app/embed.js b/src/app/embed.js index f88568094..a1485fe7e 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -249,9 +249,11 @@ export function installViews({ // A framing page talks to the app across frames; an inline panel shares // the page with it and talks through the page's own window. const peer = isEmbeddedInline() ? windowRef : windowRef.parent; - const post = (message) => { + // Answers go back only to the origin that asked. An inline panel posts to + // its own window, and a sandboxed framing page has no origin to name. + const post = (message, origin = '*') => { if (peer && (peer !== windowRef || isEmbeddedInline())) - peer.postMessage(message, '*'); + peer.postMessage(message, origin); }; // Views apply one at a time, in the order they arrive. let queue = ready; @@ -259,27 +261,37 @@ export function installViews({ if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE) return; const { id = null } = event.data; + const replyOrigin = + isEmbeddedInline() || !event.origin || event.origin === 'null' + ? '*' + : event.origin; let view; try { view = createView(event.data.view); } catch (error) { - post({ - type: EMBED_APPLIED_MESSAGE, - id, - ok: false, - error: error.message, - }); + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + error: error.message, + }, + replyOrigin, + ); return; } queue = queue.then(async () => { if (signal?.aborted) return; const steps = await applyView(view, { viewer, dataManager, run, signal }); - post({ - type: EMBED_APPLIED_MESSAGE, - id, - ok: steps.every((step) => step.ok), - steps, - }); + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: steps.every((step) => step.ok), + steps, + }, + replyOrigin, + ); }); }; windowRef.addEventListener('message', onMessage); diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index acbf87baa..5ef80090d 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -102,7 +102,13 @@ test('a followed entity is retried until its layer has it', async () => { test('an embedded page takes views only from its parent and answers it', async () => { const posted = []; - const parent = { postMessage: (message) => posted.push(message) }; + const targets = []; + const parent = { + postMessage: (message, target) => { + posted.push(message); + targets.push(target); + }, + }; const windowRef = new EventTarget(); windowRef.parent = parent; windowRef.document = { body: { classList: new Set() } }; @@ -126,9 +132,9 @@ test('an embedded page takes views only from its parent and answers it', async ( await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(shell.clean, true); assert.deepEqual(posted, [{ type: 'gev:ready' }]); - const send = (source, data) => { + const send = (source, data, origin = 'https://host.example') => { const event = new Event('message'); - Object.assign(event, { source, data }); + Object.assign(event, { source, data, origin }); windowRef.dispatchEvent(event); }; send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } }); @@ -154,6 +160,13 @@ test('an embedded page takes views only from its parent and answers it', async ( ), ); assert.equal(posted.filter((message) => message.id === 1).length, 0); + // Ready names no data and goes to any parent; answers go only to the + // origin that asked. + assert.deepEqual(targets, [ + '*', + 'https://host.example', + 'https://host.example', + ]); remove(); }); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index a7c79e53f..31fe32107 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -135,4 +135,11 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'none'", }); + // Unset, no page may frame even embed-mode documents. + for (const ancestors of ['', ' ']) { + const plugin = embedFramingPlugin({ ancestors }); + assert.equal(plugin.name, 'embed-framing'); + assert.equal(plugin.configureServer, undefined); + assert.equal(plugin.configurePreviewServer, undefined); + } });