feat(auth): add optional admin PIN for all API writes

Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/*
request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also
closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog
and the pending save is retried after login. Reads stay open because the public overlay
hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same
routes by client address.

The PIN hash and signing secret live in data/, outside the app_state blob, so they
never leak through GET /api/state, exports or backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-24 22:14:58 +02:00
parent bcd6e12a6c
commit 9016ce02e4
20 changed files with 627 additions and 8 deletions

View File

@@ -219,6 +219,17 @@ Uses:
- `logs/server.out.log`
- `logs/server.err.log`
### `server/auth.js`
Role:
- optional admin PIN protection (CommonJS, required by `server.js`)
Owns:
- PIN hashing/verification (scrypt) in `data/auth.json` and the per-installation HMAC secret in `data/auth_secret` - both outside `app_state` on purpose
- signed 30-day login cookie (`rc_auth`), invalidated when the PIN changes
- login throttling per client address
- `middleware`: once a PIN exists, rejects unauthenticated non-GET `/api/*` requests and `GET /api/ammc/config` with `401 { error: "auth_required" }`; applies `RC_ADMIN_ALLOWED_CIDRS` (403 `ip_not_allowed`) to the same routes regardless of PIN
- routes wired in `server.js`: `GET /api/auth/status`, `POST /api/auth/login`, `/logout`, `/pin`, `/disable`
### `scripts/record-video-guides.mjs` and `scripts/capture-screenshots.mjs`
Role:
- documentation tooling - not run in production, not imported by `server.js` or any `src/*.js` module
@@ -531,6 +542,15 @@ Imported by:
- `src/settings.js` for the "Banlayouter" panel (add/list/delete, delete blocked while any session still references the layout)
- `src/results_archive_view.js` to show which layout a completed session ran on
### `src/auth_view.js`
Type:
- admin PIN UI
Owns:
- `installAuthFetchGuard`: wraps `window.fetch` once at startup (`app.js`) so a `401 auth_required` from `/api/*` opens the PIN dialog; `app.js` retries the pending sync after a successful login
- `showPinDialog`: the login modal
- `mountAuthSettings`: the "Admin PIN" panel in Settings -> System & backup (enable, change, log out, disable)
### `src/misc_views.js`
Type:
- misc page renderers

View File

@@ -163,3 +163,15 @@ No other new route introduces a new class of risk; each falls under a finding al
### New dev-only tooling - no production exposure
`scripts/record-video-guides.mjs` and `scripts/capture-screenshots.mjs` (this session's video-guide/screenshot generators) are not imported by `server.js` or any `src/*.js` module, are never started by any HTTP route, and are meant to be run manually by a developer against an isolated temp server/SQLite instance - they have no network-reachable attack surface and were excluded from further review on that basis beyond the injection-safety check above.
## Follow-up - 2026-09-24: optional admin PIN
The "Critical: administration API has no authentication" finding and the 2026-09-12 `/api/update/run` finding are now **mitigable, but not closed by default**: an optional admin PIN (`server/auth.js`, off until set in Settings -> System & backup) protects every non-GET `/api/*` route plus `GET /api/ammc/config` - which covers state replacement, AMMC config/start/stop (the executable-path risk), passings, PDF export, and the update/restart trigger. `RC_ADMIN_ALLOWED_CIDRS` can additionally restrict those routes by client address, with or without a PIN.
Deliberately still open, so the original findings remain valid for an installation that hasn't set a PIN, and partly even for one that has:
- Reads (`GET /api/state`, `GET /api/passings`, backups listing) stay unauthenticated because the public overlay hydrates through `GET /api/state` - the "High: public overlay token does not protect the state API" finding is unchanged until a sanitized public-overlay endpoint exists.
- No rate limiting beyond the login throttle (10 failed PINs per 5 minutes per address).
- A cross-origin admin UI (`RC_ALLOWED_ORIGINS`) can't use the cookie, which is same-origin (`SameSite=Strict`) by design.
Design notes: the PIN hash (scrypt, salted) lives in `data/auth.json`, never in the `app_state` blob, so it can't leak via `GET /api/state`, a JSON export or a backup. The login cookie is `HttpOnly`, `SameSite=Strict`, HMAC-signed with a per-installation secret (`data/auth_secret`), valid 30 days, and invalidated by any PIN change. Token signatures are format-checked strictly, because `Buffer.from(hex)` silently drops a trailing odd character (caught by the unit tests while this was built).