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
1 change: 1 addition & 0 deletions client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)' }}
/>
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/PluginWidgetWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '*';

Expand Down Expand Up @@ -218,6 +222,7 @@ const PluginWidgetWrapper = ({
overflow: 'hidden',
}}
sandbox={PLUGIN_FRAME_SANDBOX}
allow={PLUGIN_FRAME_ALLOW}
/>
</Box>
);
Expand Down
9 changes: 8 additions & 1 deletion docs/architecture/plugin-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,15 @@ is isolated from HomeGlow (mrramam/HomeGlow#50, #49):
its own `/api/plugin/v1/…/<id>` 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 `<img>`, 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 `<img>`, 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.
Expand Down
7 changes: 7 additions & 0 deletions docs/guides/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(`<script src="/plugin-sdk/v1.js">`, `<link href="/index.css">`, `<img>`):
a `GET` isn't refused, it just isn't readable by your page unless it's one
of the routes above.
- **`localStorage` is your own**, on this display. The real one throws in a
sandbox, so HomeGlow puts a stand-in in its place: the same methods
(`getItem`, `setItem`, `removeItem`, `clear`, `key`, `length`, and
Expand All @@ -874,6 +879,8 @@ Your page runs in an iframe sandboxed **without `allow-same-origin`**
as `homeglow:data-changed` (§5) and `HomeGlow.celebrate()` already do.
- **`window.__HOMEGLOW_PLUGIN__` is fixed.** Your page can't claim another
plugin's id; the server checks the token, not the id you send.
- **Location:** your page may ask for it (`navigator.geolocation`); the
browser asks the household first.
- **Other sites:** `fetch` to a third-party API works if that API sends CORS
headers for any origin (`*`); otherwise use the proxy. Your token is never
sent there.
Expand Down
8 changes: 8 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,14 @@ fastify.addHook('onRequest', async (request, reply) => {
const decision = pluginRequestPolicy.decide(request, getPluginTokenSecret);
if (!decision.applies) return undefined;
if (!decision.allow) {
// A GET or HEAD the policy refuses still goes through, unreadable: the
// CORS plugin adds no headers for it, so the frame can't see the answer,
// and no GET route changes anything. Refusing it outright broke a plugin
// page's own script, style and image loads (/plugin-sdk/v1.js) in
// browsers that send Origin: null with them. A fetch the policy refuses
// (Sec-Fetch-Mode: cors) gets the readable 403, so its author sees why.
const read = request.method === 'GET' || request.method === 'HEAD';
if (read && request.headers['sec-fetch-mode'] !== 'cors') return undefined;
// Readable by the plugin, so its author sees why.
reply.header('Access-Control-Allow-Origin', 'null');
return reply.status(403).send({ error: decision.reason });
Expand Down
33 changes: 31 additions & 2 deletions server/tests/pluginIsolation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,23 @@ async function api(pathname, options = {}) {
return { status: response.status, body, headers: response.headers };
}

// As a plugin's frame asks: Origin null, and the page's token when it has one.
// As a plugin's frame asks with fetch: Origin null, Sec-Fetch-Mode cors (as
// browsers send for fetch and XHR), and the page's token when it has one.
const fromPlugin = (pathname, token, options = {}) => api(pathname, {
...options,
headers: { Origin: 'null', ...(token ? { 'X-HomeGlow-Plugin-Token': token } : {}), ...(options.headers || {}) },
headers: { Origin: 'null', 'Sec-Fetch-Mode': 'cors', ...(token ? { 'X-HomeGlow-Plugin-Token': token } : {}), ...(options.headers || {}) },
});
// As a plugin page loads a script, style or image: a GET, no token. Some
// browsers send Origin: null with these; some send no Sec-Fetch-Mode at all.
// Plain http, since Node's fetch sets Sec-Fetch-Mode: cors itself.
const subresource = (pathname, mode) => new Promise((resolve, reject) => {
const http = require('node:http');
const req = http.get(`${baseUrl}${pathname}`, { headers: { Origin: 'null', ...(mode ? { 'Sec-Fetch-Mode': mode } : {}) } }, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body, headers: { get: (name) => res.headers[name.toLowerCase()] ?? null } }));
});
req.on('error', reject);
});

const manifestPage = (manifest) => `<html><head><title>t</title><script type="application/json" id="homeglow-manifest">${JSON.stringify(manifest)}</script></head><body>p</body></html>`;
Expand Down Expand Up @@ -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',
Expand Down
Loading