Files
Live_RC/docs/security-performance-audit.md
larssand ae445097b0 Re-run the security audit against everything shipped since 2026-08-28
npm audit stays clean (0 vulnerabilities). Checked every new db.prepare()/spawn()/
execFileSync() call added since the last audit for injection risk - none found, all
parameterized SQL and array-form process args. Confirmed the CORS/security-header
middleware still covers every route added since, including the new ones.

One new notable finding: POST /api/update/run unconditionally stops the server before
checking whether an update is even available, making it a more direct denial-of-service
primitive than the app's other already-documented unauthenticated endpoints. No code
change made - tracked alongside the deferred permissions/auth work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:22:31 +02:00

166 lines
13 KiB
Markdown

# Security and Performance Audit
Audit date: 2026-08-28
## Scope
This review covers the Node/Express backend, SQLite persistence, browser runtime, decoder WebSocket processing, overlay refresh path, dependency tree, and bundled runtime assets. It is a source-level review plus the repository test and npm vulnerability scans. It is not a penetration test of a deployed network.
## Verification baseline
- `npm audit --omit=dev`: 0 known vulnerabilities across 105 production dependencies
- `npm test`: 77 tests passing after the changes in this audit
- `npm run verify`: Express and SQLite runtime verified
- Runtime target: Node.js 22-26
## Changes applied during this audit
### Overlay rendering
The overlay previously rebuilt its complete HTML tree from three independent loops: backend hydration every 800 ms, live refresh every 250 ms, and the one-second session timer. This caused repeated leaderboard calculation, HTML generation, DOM replacement, image handling, and event-listener registration.
The live 250 ms path now updates only:
- elapsed/remaining clock nodes
- predicted-lap text
- predicted-lap progress width and status color
Full overlay rendering remains event-driven for passings and state changes, and follows backend hydration for remote overlay clients.
### Backend synchronization
The backend state poll now runs only while the current view is `Overlay`. It no longer hydrates the full application state every 800 ms while an operator edits Settings, Race Setup, Drivers, Cars, or other administration views.
### Decoder messages
Only recognized decoder passings that mutate race state now trigger state persistence and a view render. Status, keepalive, malformed, ignored, and duplicate channel messages no longer serialize the complete state or rebuild the active page.
### Cross-origin requests
The backend no longer returns wildcard CORS headers. Requests with an `Origin` header are accepted from the server's own origin or from origins explicitly listed in `RC_ALLOWED_ORIGINS`. Other origins receive `403`, including simple cross-origin POST requests that do not use a browser preflight.
Responses also include:
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: no-referrer`
## Security findings
### Critical: administration API has no authentication
All users that can reach port 8081 can currently read or replace application state and operate managed AMMC. The most sensitive endpoints are:
- `GET/POST /api/state`
- `GET/POST /api/ammc/config`
- `POST /api/ammc/start`
- `POST /api/ammc/stop`
- `POST /api/passings`
- `POST /api/export/pdf`
AMMC configuration includes an executable path and arguments. Because the backend can start that executable, access to these endpoints must be treated as administrative host access.
Required deployment rule: do not expose port 8081 directly to the public internet. Keep it on a trusted race LAN, behind a VPN, or behind an authenticated reverse proxy.
Recommended code change: add authenticated operator sessions or an admin API token, protect every mutating/admin endpoint, and keep public timing endpoints separate.
### High: public overlay token does not protect the state API
`PUBLIC_OVERLAY_TOKEN` gates the public overlay HTML route, but the overlay still hydrates through the general `/api/state` endpoint. That endpoint contains full operational state and is not token-protected or sanitized.
Therefore `PUBLIC_OVERLAY_TOKEN` is only a route gate, not complete data/API security. Before publishing an overlay outside the race LAN, add a read-only, sanitized public-overlay state endpoint and make public overlays use only that endpoint.
### Medium: monolithic state import increases stored-XSS impact
Most dynamic UI values are escaped before insertion into HTML. However, imported JSON and unrestricted `/api/state` replacement can place attacker-controlled identifiers and configuration into many UI paths. Authentication and strict import schema validation should be the primary controls. A Content Security Policy should be added after inline styles have been removed or explicitly accounted for.
### Medium: large request bodies and no rate limiting
The global JSON limit is 20 MB and administrative endpoints have no request-rate control. This is acceptable only on a trusted LAN. Add per-route limits and rate limiting before any internet-facing deployment.
### Medium: bundled AMMC binaries are trusted artifacts
The repository includes large platform binaries and an archive. Installation currently verifies presence, not cryptographic hashes or signatures. Record vendor versions and expected SHA-256 hashes, then verify them during installation or release packaging.
### Low: health endpoint exposes the database path
`/api/health` returns the absolute SQLite path. This is useful for local diagnostics but unnecessary on a public deployment. Remove it or expose detailed health data only to authenticated operators.
## Performance findings
### High: monolithic state serialization and SQLite rewrite
`saveState()` serializes the complete application state into localStorage. Backend synchronization stores the same state as one large JSON document. `/api/passings` also parses and rewrites the state document to keep remote overlays current.
Cost grows with all historical sessions, results, passings, training records, and audio events. This is the main long-term scaling limit.
Recommended design:
1. Keep configuration and small UI state in `app_state`.
2. Store sessions, competitors, results, passings, training laps, and audit events in normalized SQLite tables.
3. Update only the active session result on a passing.
4. Publish active-session deltas through WebSocket or Server-Sent Events instead of polling full state.
### Medium: leaderboard and track-record calculations repeat on each passing
A valid passing builds the leaderboard before and after mutation, scans prior class results for the track record, and can build it again during rendering. This is acceptable for roughly 10-12 cars but will become visible with larger endurance histories.
Recommended design: cache active-session leaderboard inputs and per-class track records; invalidate only the affected competitor and class.
### Medium: full view replacement on each valid passing
Timing and overlay views still replace substantial HTML after each valid passing. The 250 ms overlay animation path is now incremental, but passing-driven rendering remains full-page. Move leaderboard rows to keyed incremental updates if profiling shows frame drops during larger races.
### Low: source delivery is unbundled and unminified
ES modules are appropriate for the current local deployment. The size of `app.js` affects maintainability more than runtime by itself, but production releases would benefit from bundling, minification, hashed assets, and long-lived cache headers. Development currently uses `no-store` intentionally to support live file reload.
### Distribution size
The AMMC archive and multi-platform binaries dominate repository and deployment size. Build platform-specific release archives instead of syncing all AMMC targets to each timing computer.
## Recommended implementation order
1. Add admin authentication and a sanitized public overlay API.
2. Replace full-state overlay polling with server-pushed active-session deltas.
3. Normalize active race results and training history into SQLite tables.
4. Add request limits, rate limiting, import schema validation, and CSP.
5. Add AMMC checksum verification and platform-specific release packaging.
6. Profile a simulated 12-car race and an endurance dataset before deeper leaderboard optimization.
## Follow-up review — 2026-09-12
Requested by the user: re-check dependencies and everything added to `server.js`/the module tree since the 2026-08-28 audit above (a long session's worth of features: track layouts, race day scheduling, staggered starts, driver transponder-conflict warnings, Settings/Event Manager/Guide tab restructurings, the video-guide and screenshot capture tooling, and several new backend routes). This is a source-level re-check, not a new penetration test.
### Verification baseline
- `npm audit` and `npm audit --omit=dev`: **0 known vulnerabilities**, both re-run 2026-09-12 (3 direct production dependencies: `better-sqlite3`, `express`, `qrcode`; ~200 packages in the full production tree).
- `npm test`: 205 tests passing; `npm run test:browser`: 37 scenarios passing.
- Node runtime observed: v22.23.2 (within the `>=22 <27` engines range).
- Checked every `db.prepare()`/`db.exec()` call in `server.js` (12 total) for injection risk: all parameterized (`?` placeholders); the one call that builds SQL with a template literal (`/api/passings/purge`'s dynamic `IN (...)` clause) only interpolates a computed number of `?` placeholders, never request data itself - the actual values are still bound via `.run(...sessionIds)`.
- Checked every `spawn()`/`execFileSync()` call reachable from the server (in `server.js`, `scripts/serverctl.js`, and the two new dev-only tooling scripts) for shell-injection risk: all use array-form arguments with no `shell: true` and no string-interpolated shell commands, so none are exploitable via shell metacharacters in a config value or filename.
- Re-confirmed the CORS/security-header middleware from the 2026-08-28 audit (`X-Content-Type-Options`, `Referrer-Policy`, origin allowlist via `RC_ALLOWED_ORIGINS`) is registered before every route added since, including the new ones below - no regression.
### New finding - High: `POST /api/update/run` is an unauthenticated remote restart/update trigger
Added since the last audit (the in-app update checker, ROADMAP.md "In-app update checker and one-click updater"). This inherits the existing "Critical: administration API has no authentication" finding above, but is worth calling out on its own: unlike most other unauthenticated endpoints, which expose or mutate *data*, this one is a direct **disruption primitive**. Any client that can reach the server -
- **unconditionally stops the running server first**, before checking whether an update is even available or possible (`scripts/serverctl.js`'s `update()` calls `stop()` immediately) - so a bare `POST /api/update/run` with no real update pending still causes a guaranteed brief outage, repeatable at will (no rate limiting exists anywhere in the app - see the pre-existing "Medium: large request bodies and no rate limiting" finding above);
- then runs `git pull --ff-only` and, if that succeeds, `npm install` against whatever the configured git remote/npm registry currently serve, before restarting.
The `--ff-only` pull and graceful fallback-to-restart-on-failure (confirmed by reading `scripts/serverctl.js`) mean this cannot rewrite git history or corrupt the working tree, and a failed pull still leaves the server running on its current code - so this is not an arbitrary-code-execution vector by itself. But on a shared trackside LAN, an unauthenticated repeated-restart trigger during a live event is a real, easy-to-trigger denial of service, more severe in practice than most of the "no auth" list above because it requires no knowledge of the app's data model to abuse - just knowing the URL.
Same LAN-only deployment mitigation as the existing "Critical" finding applies today (do not expose port 8081 to the public internet); no code change made as part of this review. Worth prioritizing ahead of the rest of the deferred auth work if a minimal interim gate (e.g. restricting this one route to loopback requests, or requiring a simple shared-secret header) is wanted before the full permissions system is designed - see ROADMAP.md, "Permissions and mobile Race Control."
### New routes since 2026-08-28, otherwise covered by existing findings
No other new route introduces a new class of risk; each falls under a finding already listed above:
- `GET /api/update-status` - read-only, same "no auth" umbrella as the rest of `/api/*`.
- `GET /api/network/public-address` - calls a fixed, hardcoded external URL (`api.ipify.org`) server-side with a 5s timeout; not user-controlled, so no SSRF risk. Same "no auth" umbrella (anyone can trigger an outbound request, but only ever to that one fixed address).
- `POST /api/passings/purge` - mutating, parameterized SQL (see above), same "no auth" umbrella.
- `POST /public-training/api/:shareToken/even-laps`, `.../step-training`, `.../exercise-sessions` - each still validated against the existing per-token authorization model documented in the 2026-08-28 audit's scope (shareToken lookup, 404 on invalid/disabled/unknown) and each read-modify-writes only its own slice of `state.training` server-side (`server/public_training_profile.js`) rather than accepting a client's full state blob, the same scoped-write pattern the original audit already treated as a mitigating factor for the mobile self-service surface.
### 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.