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>
166 lines
13 KiB
Markdown
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.
|