Skip to content

fix(plugins): isolate plugins from HomeGlow and from each other - #320

Merged
jherforth merged 1 commit into
mainfrom
fix/plugin-isolation
Oct 23, 2026
Merged

jherforth merged 1 commit into
mainfrom
fix/plugin-isolation

Conversation

@jherforth

@jherforth jherforth commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Draft: don't merge yet. The release order is still being decided (1.10 first, or with this).

Fixes mrramam#50, #49 and the CORS part of #62. Until now, a plugin could do anything the Admin Panel can:

  • write settings, the proxy whitelist included;
  • export the database and its encryption key when no PIN is set;
  • stage a restore and restart the server;
  • use another plugin's storage, notifications or Home Assistant panel by naming its id.

That was possible because plugin frames were sandboxed with allow-same-origin and served from HomeGlow's own origin, and the API allowed any origin.

What changes

  • Opaque sandbox.

    • Plugin frames (and the Admin preview) drop allow-same-origin. A plugin can't reach the dashboard's DOM or storage, or read another plugin's page.
    • The dashboard posts to frames with "*", and accepts their messages from origin "null", still only from its own frame.
  • A token per page.

    • /widgets/:filename injects a frozen __HOMEGLOW_PLUGIN__, plus a frame script (server/utils/pluginFrameShims.js).
    • The script carries an HMAC token for plugin:<id>, or widget:<filename> for a plain widget. It's signed with a new PLUGIN_TOKEN_SECRET setting, which is made on first use and never returned.
    • The script adds the token to fetch and XHR calls to HomeGlow's own host, and to nothing else. Plugins and the SDK need no change.
  • One request policy (server/utils/pluginRequestPolicy.js). A request with Origin: null needs a valid token, and reaches only:

    • its own /api/plugin/v1/{storage,settings,ha,notify}/<id> routes;
    • the core reads in the plugin guide (§10.2), plus its four household writes: clams in and out, a chore done or undone;
    • the proxy;
    • theme files, with no token, because FontFace can't add one.

    Everything else answers 403 with a reason. /api/calendar-sources leaves out url and username for plugins, since a private ICS link is a secret.

  • CORS.

    • @fastify/cors now answers per request instead of *: a plugin's allowed requests, the dev server, and a new CORS_ORIGINS list.
    • The proxy and the event stream no longer set * by hand.
  • localStorage throws in an opaque origin, so the frame script puts a stand-in in its place, per plugin and per display.

    • It's seeded through the frame's name and kept by the dashboard (client/src/utils/pluginLocalStore.js).
    • The published plugins' old keys are carried over once per display, for the plugins installed at that moment.
    • The original keys stay where they were, for a rollback.

Compatibility

  • Published plugins need no change to keep working. All ten I ran work as they are, with their saved data carried over.
  • NewsTicker used to add its own feed hosts to the whitelist. From its code, the old version's Allow it button would now fail and show "Could not update the proxy whitelist". I didn't run that. NewsTicker 1.0.1: stop writing the proxy whitelist itself HomeGlowPlugins#60 (1.0.1) replaces that with a pointer to System → Connections, and works on older HomeGlow too.
  • Third-party plugins:
    • a call to a core route outside the list now gets 403;
    • localStorage keys saved before the upgrade aren't carried over, only the published plugins' are.

Testing

  • Server npm test:
    • the full suite passed except one theme test, which asserted the old global *. That test is updated and passes, and asks as a plugin frame does.
    • new pluginRequestPolicy.test.js: tokens, routes, preflights, public files, origins.
    • new pluginIsolation.test.js, on a real server with requests sent as a sandboxed frame sends them:
      • no or forged token: refused;
      • another plugin's storage, settings, notify and Home Assistant routes: refused;
      • core reads: open, with the calendar address stripped;
      • settings, backup, restart, PIN, install, stream and widget-page requests: refused;
      • preflights answered only where allowed;
      • foreign origins get no CORS headers;
      • no-Origin requests unchanged.
  • Client: vitest (749, including new pluginLocalStore.test.js), check:i18n and build all pass.
  • Edge, with ten published plugins plus a probe plugin on one display: 32 of 32 checks pass.
    • All ten plugins render: GuestWifi, Grill, Polls, CalendarAgenda, Chore Metrics, DateClock, Countdown, Messages, WeatherGlance, NewsTicker.
    • Every escape the probe tried was refused: the parent page, the dashboard's storage, settings writes, backup export, another plugin's page and storage, and renaming itself.
    • Core reads, its own storage, localStorage (by method and by property), theme files, the theme message and a declared event all work.
    • GuestWifi showed a network saved before the upgrade, and Grill got only its own key.
    • localStorage persists across a reload.
  • A Home Assistant panel against the fake Home Assistant: it read a switch's state and toggled it through the token path.
  • Not tested:
    • a real install with real data;
    • touch swipe, expand and celebrate (they share the message check the tests cover);
    • production's same-origin setup in a browser. The Edge runs used the Vite dev server, where the dashboard and API are on different ports.

🤖 Generated with Claude Code

Reported in mrramam#50, #49 and #62 (the CORS part). Plugin frames
were sandboxed with allow-same-origin and served from HomeGlow's origin, and
the API allowed any origin, so a plugin could do anything the Admin Panel
can: write settings (the proxy whitelist included), export the database and
its key, stage a restore, restart the server, and use another plugin's
storage, notifications or Home Assistant panel by naming its id.

- Opaque sandbox: plugin frames (and the Admin preview) drop
  allow-same-origin. The dashboard posts to them with "*" and accepts their
  messages from origin "null", still from its own frame only.
- A token per page: /widgets/:filename injects a frozen identity and a frame
  script (utils/pluginFrameShims.js) carrying an HMAC token for
  "plugin:<id>" (or "widget:<filename>"), signed with PLUGIN_TOKEN_SECRET
  (made on first use, redacted). The script adds it to fetch and XHR calls to
  HomeGlow's own host only, so plugins and the SDK need no change.
- One request policy (utils/pluginRequestPolicy.js): a request with
  Origin: null needs a valid token, and reaches only its own
  /api/plugin/v1/{storage,settings,ha,notify}/<id> routes, the core reads and
  the four household writes the plugin guide documents, the proxy, and theme
  files (no token: FontFace can't add one). Everything else answers 403.
  calendar-sources leaves out url and username for plugins.
- CORS: @fastify/cors answers per request instead of "*": a plugin's allowed
  requests, the dev server, and a new CORS_ORIGINS list. The proxy and the
  event stream no longer set "*" by hand.
- localStorage: the frame script puts a per-plugin stand-in in its place
  (the real one throws in an opaque origin), seeded through the frame's name
  and kept by the dashboard per plugin and display
  (client/src/utils/pluginLocalStore.js). The published plugins' old keys
  are carried over once per display, for the plugins installed then only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@mrramam

mrramam commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

I checked this against a bunch of plugin's API calls, plus the plugins we run in production.

  • Routines breaks. It creates, edits, hides and deletes chores and schedules: POST /api/chores, PATCH and DELETE /api/chores/:id, and POST, PATCH and DELETE /api/chore-schedules/:id. PLUGIN_POSTS only has complete, uncomplete and the clams routes, so setting up or changing a routine gets a 403. It isn't one of the ten you ran. The other published plugins only call routes in READ_ROUTES and PLUGIN_POSTS, apart from NewsTicker's settings write, which HomeGlowPlugins#60 handles.
  • CSRF: agree with leaving it to README calls the Admin PIN access control; the PIN is an unsalted SHA-256 and the API allows any origin mrramam/HomeGlow#62. The multipart uploads are the sharpest case: /api/widgets/upload, /api/themes/upload and the photo, sound and background uploads.
  • Smaller: a no-cors GET from a plugin frame carries no Origin, so the policy never sees it. The plugin can't read the answer, so this only matters for a GET that changes state. I haven't checked which GET routes do.

It merges cleanly onto my production build, with #319 too.

@jherforth

jherforth commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner Author

-Routine is built in so it would only break if someone reinstalls it and I think we can consider the plugin deprecated.
-Cross site can be addressed in your mrramam#62 issue.
-I checked every GET route the server has, and none changes anything that matters. The Google sign-in callback needs a code the server issued itself. One small nuisance: ?refresh=1 on the store listing and the weather skips their caches. A plugin could repeat it to use up GitHub's hourly limit or the weather key's daily quota. It harms nothing on the network, so I'd add it to the follow-ups.

I'm going to push 1.10 and this will not be merged in that release, this will go in 1.11 and can be merged to main once 1.10 goes live.

@jherforth
jherforth marked this pull request as ready for review October 23, 2026 23:46
@jherforth
jherforth merged commit db48bb0 into main Oct 23, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Oct 23, 2026
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 24, 2026
…e 1.10 jherforth#321, plugin storage/proxy fixes jherforth#319, chore timezone display jherforth#318, CI pins jherforth#316-317, prize descriptions jherforth#315, proxmox/docker fixes jherforth#314, plugin proxy docs jherforth#313, Google OAuth escaping jherforth#312, upload typing/sandbox jherforth#311

Resolve registry conflict: keep fork renumbering (local 28 userGoogleTasks, 34 googleTasksTables); upstream's new 35 proxyWhitelistSeed and 36 prizeDescription renumbered to 41 and 42 to keep schemaIds unique and matching file names.
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 24, 2026
Since jherforth#320 a plugin frame has an opaque origin, so its fetch of
/fonts/inter-*.woff2 is always CORS mode and the browser refuses it
without an Access-Control-Allow-Origin header. The dashboard itself is
same-origin and unaffected, which is why the test instance looked fine.

- Dev backend: /fonts/ joins PUBLIC_ROUTES, so an opaque-origin GET
  needs no plugin token (the CORS delegator then reflects the origin,
  as it already does for theme fonts).
- Containers: a ^~ /fonts/ location in nginx.conf serves the files with
  Access-Control-Allow-Origin: * ahead of the static-asset regex, which
  adds no CORS header.

Tests: plugin policy, SDK theme, and isolation suites pass (28/28).
@jherforth jherforth self-assigned this Oct 24, 2026
@jherforth jherforth added the bug Something isn't working label Oct 24, 2026
@jherforth jherforth added this to the 1.11 milestone Oct 24, 2026
@jherforth jherforth moved this from Done to Ready to Review in HomeGlow Kanban Oct 24, 2026
@jherforth jherforth moved this from Ready to Review to Done in HomeGlow Kanban Oct 24, 2026
jherforth added a commit that referenced this pull request Oct 24, 2026
…e location (#323)

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 <[email protected]>
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 25, 2026
Plugin frames have opaque origins since jherforth#320, so they cannot share
the dashboard's font cache and re-downloaded the 350KB Inter files on
every load. The @font-face rules move out of index.css (which plugin
frames link as /index.css) into fonts.css, imported by main.jsx, with
the woff2 files bundled by Vite from client/src/assets/fonts/ into
hashed assets the dashboard fetches once and serves from cache.

Removed what only plugins needed: server/fonts/ (woff2, OFL, README),
the @fastify/static /fonts/ registration, the /fonts/ PUBLIC_ROUTES
entry and its test, and the nginx /fonts/ location (its CORS header
existed for plugin frames; the dashboard's hashed assets are covered
by the generic static-asset cache headers). Release notes call out the
system-font-to-Inter change for most households.
jherforth added a commit to adamecker/HomeGlow that referenced this pull request Oct 25, 2026
- "Idle seconds before dimming" moves up beside "Dim the display when
  idle": it's when dimming starts, not part of the night hours. Under
  "Night hours" are just its start and end.
- The "Night hours" heading gets space below it; the fields' floating
  labels overlapped it by 9 px.
- Idle seconds is saved as a number (editWholeNumber), not the field's
  text.
- homeglow:activity counts only from a frame on this page, like every
  other plugin message since jherforth#320.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jherforth added a commit that referenced this pull request Oct 25, 2026
* feat(appearance): configurable display dimming as an appearance setting

Move the fork's kiosk dimming (CSS brightness filter, idle fade,
day/night targets) from URL params + localStorage into the appearance
cascade: a dimming object field with household default and per-display
override, normalized and clamped like autoDark. The old localStorage
keys seed a display override once via the legacy-migration pattern
(with their own marker, so a theme migrated long ago does not block
the dim seed). Kiosk URL params still override per-field for the
session, and saving in Admin applies live without a reload.

Generated with Muse AI 🤖

* docs: screenshot of the dimming settings for the upstream PR

* feat(appearance): show dimming night hours as times and slider percentages live

Night start/end show their formatted time under the hour field, using
the app's standard time format (12-hour where that is the locale
convention, 24-hour elsewhere). Brightness headings now carry the live
percentage, so the value is visible while the slider moves, not only in
the drag bubble.

* feat(appearance): dimming gets its own card, scope-isolated saves, household timezone

Address jherforth's + mrramam's review on the dimming PR:

- Own card: dimming moves out of AppearanceSettings into
  DisplayDimmingSettings, rendered in the Look tab between Appearance
  and Themes as its own AdminFormSection with its own scope toggle,
  Use-household switch and Save button.
- Scope isolation: both cards save through the new appearanceSave
  helper, which re-reads the stored appearance and merges only its
  own fields (device saves delete fields switched to Use household
  instead of omitting them). Saving dimming, then Appearance without
  reloading, leaves dimming intact, and vice versa; Make-household-
  default preserves every display's dimming override.
- Labels: slider headings are subtitle2 with the live percentage
  after the label; the three number fields sit under a subtitle2
  Night hours heading (new en+es string).
- Night start/end are Selects of the 24 hours, labelled with
  formatHourOfDay; idle seconds stays a number field.
- Dimming pauses while the Admin Panel is open and resumes after a
  full idle delay on close.
- Dropped fork-only pieces: kiosk URL parameters (?dim, ?dimNight,
  ?dimAfter, ?nightStart, ?nightEnd), the window.hgSetDim hook, and
  the hg-dim/hg-dim-night/hg-dim-after legacy seeding with its
  dimmingMigrated marker (theme migration untouched).
- Plugin frames now report pointer/key/wheel use as
  homeglow:activity (always on, 1/sec throttle) and displayDim
  counts it as activity, so using a plugin holds the dim off.
- The night window is read in the household timezone
  (getServerTimezoneSync) via Intl, with a local-hours fallback for
  unknown zones.
- The PR-body screenshot is deleted from the repo.

No low-end-panel frame-rate measurement: the dimming filter sits
on <html> and theme animations are transform/opacity-only, so it
is addressed by mechanism, not measured on hardware.

Generated with Muse AI 🤖

* fix(appearance): idle seconds sits with the switch, Night hours has room

- "Idle seconds before dimming" moves up beside "Dim the display when
  idle": it's when dimming starts, not part of the night hours. Under
  "Night hours" are just its start and end.
- The "Night hours" heading gets space below it; the fields' floating
  labels overlapped it by 9 px.
- Idle seconds is saved as a number (editWholeNumber), not the field's
  text.
- homeglow:activity counts only from a frame on this page, like every
  other plugin message since #320.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: adamecker <[email protected]>
Co-authored-by: jherforth <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
@jherforth
jherforth deleted the fix/plugin-isolation branch October 28, 2026 00:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Plugin iframe sandbox gives no isolation

2 participants