Announcements can use Piper, a free local neural TTS, instead of the browser's system voice. No account, no paid service, and it keeps working with no internet at the track. - npm run tts:install (or Settings -> Audio -> Install) downloads the standalone Piper release for Linux/Windows/macOS plus Swedish and English voices into piper/ once - server/tts.js generates each line once, one at a time, and caches it as WAV under data/tts/; a new line takes ~0.3 s with the standalone build - src/piper_speech.js plays it and falls back to the browser voice if Piper is missing, errors, or takes over 2.5 s, so no announcement is lost and timing is never held up - "Pre-generate phrases and driver names" renders every fixed phrase and name in both languages before race day Browser voice stays the default. Generation routes fall under the admin PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
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 dependenciesnpm test: 77 tests passing after the changes in this auditnpm 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: nosniffReferrer-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/stateGET/POST /api/ammc/configPOST /api/ammc/startPOST /api/ammc/stopPOST /api/passingsPOST /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:
- Keep configuration and small UI state in
app_state. - Store sessions, competitors, results, passings, training laps, and audit events in normalized SQLite tables.
- Update only the active session result on a passing.
- 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
- Add admin authentication and a sanitized public overlay API.
- Replace full-state overlay polling with server-pushed active-session deltas.
- Normalize active race results and training history into SQLite tables.
- Add request limits, rate limiting, import schema validation, and CSP.
- Add AMMC checksum verification and platform-specific release packaging.
- 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 auditandnpm 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 <27engines range). - Checked every
db.prepare()/db.exec()call inserver.js(12 total) for injection risk: all parameterized (?placeholders); the one call that builds SQL with a template literal (/api/passings/purge's dynamicIN (...)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 (inserver.js,scripts/serverctl.js, and the two new dev-only tooling scripts) for shell-injection risk: all use array-form arguments with noshell: trueand 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 viaRC_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'supdate()callsstop()immediately) - so a barePOST /api/update/runwith 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-onlyand, if that succeeds,npm installagainst 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 ofstate.trainingserver-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.
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 throughGET /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).
Follow-up - 2026-09-27: local voice (Piper)
New surface, reviewed as it was built:
POST /api/tts/speech,/api/tts/pregenerateand/api/tts/installare writes, so they fall under the admin PIN once one is set.GET /api/tts/statusis open like other reads; it exposes the Piper binary path and voice folders (same class of detail as/api/health's database path - "Low" finding above).- Piper runs through
execFilewith array arguments and the text on stdin, never a shell, so text can't inject commands. Text is capped at 300 characters, and generation is serialized (one Piper process at a time, 20 s timeout), which bounds how much CPU a flood of requests can take - but without a PIN anyone on the network can still keep that one queue busy. npm run tts:install/POST /api/tts/installdownload the Piper release from GitHub and voices from Hugging Face over HTTPS, without checksum verification - the same trust model as the "Medium: bundled AMMC binaries are trusted artifacts" finding above, and the same fix applies (pin expected SHA-256 hashes).