Skip to content

fix(security): harden the admin PIN and refuse cross-site writes (mrramam/HomeGlow#62, #73) - #329

Merged
jherforth merged 1 commit into
mainfrom
fix/admin-pin-hardening
Oct 25, 2026
Merged

jherforth merged 1 commit into
mainfrom
fix/admin-pin-hardening

Conversation

@jherforth

Copy link
Copy Markdown
Owner

Closes the rest of mrramam#62, and the admin PIN part of #73.

The framing, now in the README

HomeGlow is a local-only app for a trusted home network. It's not secure, and it isn't designed to be exposed to the internet. The admin PIN is tamper resistance against household members who shouldn't make changes, not access control. The README's Security section and docs/README.md now say this up front. The reverse-proxy line no longer reads as an invitation to publish HomeGlow: if a proxy reaches the internet, it says to put a login in front of it.

Changes

The PIN (server/utils/adminPin.js)

  • Stored as scrypt with a random salt, compared with timingSafeEqual.
    • A PIN saved by an older version (unsalted SHA-256) still works and is rehashed on its next correct entry, so nobody has to set their PIN again.
    • Every PIN check now goes through this one module: verify, the backup routes and Home Assistant unlock actions.
  • A limit on wrong guesses:
    • five free tries, then a wait of 30 seconds, doubling to five minutes;
    • while it lasts, even the right PIN gets 429 with Retry-After, or the limit would only slow a guesser down;
    • the right PIN after the wait resets the count;
    • one limit for the server, not per device: behind the bundled nginx every display has the same address;
    • a missing PIN isn't counted as a guess.

Routes that now check the PIN on the server

  • Staging a restore: POST /api/backup/local/import (a pin field before the file, checked before the upload is written) and POST /api/backup/drive/restore/stage.
  • Staging a rollback: POST /api/backup/drive/restore/rollback.
  • Cancel and restart don't need it. Cancel only drops a staged restore. Restart only applies one the PIN already allowed, and answers 400 when nothing is staged.

Writes from other websites (server/utils/crossSiteWrites.js)

#320 stopped other sites from reading the API, but a browser still sends a form or multipart POST from any page. Any write (POST/PUT/PATCH/DELETE) whose Origin is another website now gets 403.

  • How "same origin" is judged: by Sec-Fetch-Site, which the browser sets, so no proxy in between can get it wrong. An older browser without it is judged by hostname against Host / X-Forwarded-Host. Hostnames only, because nginx's Host $host drops the port.
  • Unaffected:
    • requests with no Origin (curl, Home Assistant, scripts);
    • plugin frames (Origin: null), which the plugin policy already holds to their token;
    • CORS_ORIGINS;
    • the Vite dev server.

Client

  • One shared PIN prompt for the backup actions (useAdminPinPrompt): download, restore from a file, roll back, stage a Drive restore, and download a backup's key. It checks the PIN, then closes the dialog and runs the action, so a long upload shows its progress on the page.
  • Every PIN prompt says how long to wait when the limit is hit: "Too many wrong PINs. Try again in 30 seconds." (en, es). That covers the Admin Panel gate, vacation mode and the chore actions.

Docs

backend-api.md, configuration.md and database.md describe the PIN format, the limit, the guarded routes and the cross-site rule.

Not changed, on purpose

  • The PIN doesn't guard the rest of the API. Setting or clearing the PIN, settings and chores still accept any device on the network. Guarding them would need a session issued by verify and sent with every admin write. That's a larger change, and beyond tamper resistance for a household. The README now says plainly that the PIN doesn't stop someone who calls the API directly.

Testing

  • Edge, production-like: the built dashboard and the API on one origin behind a proxy that passes Host without the port, as nginx does, with NODE_ENV=production. 10 of 10 checks pass:
    • cross-site writes: a multipart import posted from another origin gets 403 (Sec-Fetch-Site: cross-site), and a cross-site settings write isn't saved;
    • the dashboard's own writes: they go through (same-origin);
    • the upgrade: a PIN saved as SHA-256 by the previous version opens the Admin Panel and is rehashed as scrypt;
    • download: asks for the PIN even with the panel open, then downloads;
    • restore: a wrong PIN stays in the dialog; the right one reaches the file check ("could not be read as a backup archive");
    • the limit: after five wrong PINs at the gate, the right one says "Too many wrong PINs. Try again in 30 seconds.";
    • page errors: none.
  • New server tests:
    • adminPinRoutes.test.js, a real server (Calendar view #73):

      • set stores scrypt, and exists never shows the hash;
      • verify takes the right PIN and refuses a wrong or missing one;
      • an old SHA-256 PIN is rehashed;
      • import, Drive stage and rollback need the PIN, while cancel and restart don't;
      • a cross-site write gets 403, while same-origin and no-Origin writes don't;
      • the limit returns 429 on verify and on the export;
      • DELETE clears the PIN.

      All but the verify test fail on main.

    • adminPin.test.js: salting, legacy matching and rehashing, a missing PIN not counting, and the waits of 30, 60, 120, 240 and 300 seconds, capped.

    • crossSiteWrites.test.js: cross-site, same-site on another port, behind a proxy, older browsers, and the exemptions.

  • Server npm test (646), client vitest (755), check:i18n and build pass.

Left in #73

These aren't about the PIN:

  • a refreshAccessToken test for Google;
  • the first React component test (Testing Library isn't installed yet);
  • the brittle tests (the avatar count of 29, the injected-CSS string);
  • pluginStore.test.js writing into server/widgets/.

🤖 Generated with Claude Code

…amam#62, #73)

The admin PIN is tamper resistance for a household, and the README now
says so plainly: HomeGlow is local-only, not secure, and not for the
internet.

- The PIN is stored as scrypt with a random salt and compared in
  constant time. An old unsalted SHA-256 is rehashed on its next
  correct entry. All PIN checks live in utils/adminPin.js.
- Five wrong PINs, then a wait that doubles from 30 seconds to five
  minutes, even for the right PIN. One limit for every route that
  checks it (429 with Retry-After).
- Staging a restore (file or Drive) and a rollback need the PIN, like
  a backup download already did. Cancel and restart don't: they can
  only drop or apply what the PIN allowed.
- A write whose Origin is another website is refused (403), judged by
  Sec-Fetch-Site, or by host for browsers without it. CORS stopped
  other sites reading the API; this stops them posting to it.
- The backup dialogs share one PIN prompt (useAdminPinPrompt), and every
  PIN prompt says how long to wait when locked (en, es).
- Tests: the admin PIN routes on a real server (#73), the hashing and
  the limit, and the cross-site check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jherforth
jherforth merged commit b03b790 into main Oct 25, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Oct 25, 2026
@jherforth jherforth self-assigned this Oct 25, 2026
@jherforth jherforth added the bug Something isn't working label Oct 25, 2026
@jherforth jherforth added this to the 1.11 milestone Oct 25, 2026
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 26, 2026
… revision counter jherforth#339, dimming jherforth#334, Save layout jherforth#336, chore perf jherforth#324, admin jherforth#330/jherforth#331/jherforth#332, PIN hardening jherforth#329

Conflicts resolved taking upstream's rewrite, preserving fork(prod) features:
- displayDim.js: upstream jherforth#334 base plus fork-only kiosk URL dim
  overrides and LCD pixel shift (both absent upstream)
- ChoreWidget/app.jsx/ChoreSchedulesTab: upstream jherforth#324/jherforth#336 rework
@jherforth
jherforth deleted the fix/admin-pin-hardening 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.

1 participant