From c5a0fcf8e3c7afd28c0cf10c890db93cd844dab3 Mon Sep 17 00:00:00 2001 From: jherforth Date: Sat, 24 Oct 2026 16:14:12 -0400 Subject: [PATCH] fix(plugins): a plugin page's own scripts load, and it may ask for the location After #320, Music, Sunrise & Sunset and WeatherGlance broke on hgtest. - The SDK never loaded. Some browsers send Origin: null with a sandboxed page's script and style loads, and the request policy answered every Origin: null request without a token with 403, /plugin-sdk/v1.js included. Without window.HomeGlow, Music threw on HG.settings, and Sunrise & Sunset and WeatherGlance couldn't read their saved locations (WeatherGlance then asked /api/weather with no location: 404). A GET or HEAD the policy refuses now goes through without CORS headers, so the page can use but not read it; no GET route changes anything. A fetch the policy refuses (Sec-Fetch-Mode: cors) still gets the readable 403, and writes still need a token and an allowed route. - Location detection was blocked by permissions policy: a cross-origin (here opaque) frame can't ask for the location unless its iframe allows it. Plugin frames, and the Admin preview, now carry allow="geolocation". The browser still asks the household first. Co-Authored-By: Claude Opus 5.5 --- client/src/components/AdminPanel.jsx | 1 + client/src/components/PluginWidgetWrapper.jsx | 5 +++ docs/architecture/plugin-platform.md | 9 ++++- docs/guides/plugin-development.md | 7 ++++ server/index.js | 8 +++++ server/tests/pluginIsolation.test.js | 33 +++++++++++++++++-- 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 64de69bc..f5304606 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -2543,6 +2543,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ // Opaque, like the dashboard's frames (PluginWidgetWrapper): // a plugin can't act as HomeGlow from here either. sandbox="allow-scripts allow-forms" + allow="geolocation" name={frameNameFor(plugin.filename)} style={{ width: '100%', height: '100%', border: 0, display: 'block', backgroundColor: 'var(--card-bg)' }} /> diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx index 3801c2f0..54152dbb 100644 --- a/client/src/components/PluginWidgetWrapper.jsx +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -18,6 +18,10 @@ import { LOCAL_STORAGE_MESSAGE_TYPE, applyPluginStoreChange, frameNameFor } from // it say they come from "null"; messages to it can't name its origin, so they // go to "*". The source check (this wrapper's own frame) is the real gate. export const PLUGIN_FRAME_SANDBOX = 'allow-scripts allow-forms allow-popups'; +// A cross-origin (here: opaque) frame may not ask for the location unless its +// iframe allows it, and Sunrise & Sunset finds where it is that way. The +// browser still asks the household first. Scoped to the plugin's own address. +export const PLUGIN_FRAME_ALLOW = 'geolocation'; const FRAME_ORIGIN = 'null'; const TO_FRAME = '*'; @@ -218,6 +222,7 @@ const PluginWidgetWrapper = ({ overflow: 'hidden', }} sandbox={PLUGIN_FRAME_SANDBOX} + allow={PLUGIN_FRAME_ALLOW} /> ); diff --git a/docs/architecture/plugin-platform.md b/docs/architecture/plugin-platform.md index eb095060..5bf9a006 100644 --- a/docs/architecture/plugin-platform.md +++ b/docs/architecture/plugin-platform.md @@ -491,8 +491,15 @@ is isolated from HomeGlow (mrramam/HomeGlow#50, #49): its own `/api/plugin/v1/…/` routes; the core reads and the four household writes the plugin guide lists (§10.0 there); the proxy; and a theme's files (which need no token, since `FontFace` can't add one). - Everything else answers `403`. Requests with no `Origin` (an ``, a + Everything else answers `403`, except a `GET` or `HEAD` that isn't a + `fetch` (`Sec-Fetch-Mode` other than `cors`): a page's own script, style + and image loads, which some browsers send with `Origin: null`. Those go + through without CORS headers, so the page can use but not read them, and no + `GET` route changes anything. Requests with no `Origin` (an ``, a navigation) or the dashboard's own are unaffected. +- **Location:** plugin frames carry `allow="geolocation"`, since a + cross-origin frame can't ask for it otherwise (Sunrise & Sunset does). The + browser still asks the household. - **No more `Access-Control-Allow-Origin: *`.** The API answers CORS only for a plugin's allowed requests, the dev server, and `CORS_ORIGINS`, so another website can't read it. diff --git a/docs/guides/plugin-development.md b/docs/guides/plugin-development.md index e7fb3132..ca4d3426 100644 --- a/docs/guides/plugin-development.md +++ b/docs/guides/plugin-development.md @@ -861,6 +861,11 @@ Your page runs in an iframe sandboxed **without `allow-same-origin`** `HomeGlow.on`), and every other route. `/api/calendar-sources` leaves out each calendar's `url` and `username` for a plugin: a private ICS link reads the whole calendar. + + Your page's own scripts, stylesheets, images and sounds load as usual + (`p`; @@ -174,6 +187,22 @@ test('everything else is closed to a plugin, token or not', async () => { assert.equal(settings.PLUGIN_TOKEN_SECRET, undefined, 'the signing secret is never handed out'); }); +test('a plugin page\'s own scripts, styles and images load, even with Origin: null', async () => { + for (const mode of ['no-cors', undefined]) { + const sdk = await subresource('/plugin-sdk/v1.js', mode); + assert.equal(sdk.status, 200, `SDK, mode ${mode}`); + assert.match(String(sdk.body), /window\.HomeGlow/); + assert.equal((await subresource('/index.css', mode)).status, 200, `stylesheet, mode ${mode}`); + } + // Another plugin's data, loaded as if it were an image: it goes through, + // but with no CORS headers, so the page can't read it. + const other = await subresource('/api/plugin/v1/storage/plugin-b/secret', 'no-cors'); + assert.equal(other.headers.get('access-control-allow-origin'), null, 'not readable by the frame'); + // A write is never let through that way. + const write = await api('/api/settings', { method: 'POST', headers: { Origin: 'null', 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'x', value: 'y' }) }); + assert.equal(write.status, 403); +}); + test('a preflight is answered only for what a plugin may do', async () => { const preflight = (url, method) => api(url, { method: 'OPTIONS',