StadiumIQ is a hackathon demo, but its security posture is real: every control below is enforced in code and most are pinned by automated tests. This document is the map.
Assets: the Gemini API key (server secret), staff/volunteer sessions, the integrity of operational data shown to the control room (telemetry, incidents, announcements), and fans' trust (no PII is collected at all — fans never sign in).
Adversaries considered:
- Anonymous web attacker — probing APIs, brute-forcing login, spamming AI endpoints, XSS/clickjacking attempts.
- Prompt injector — a fan (or content smuggled into a request) trying to hijack the LLM: exfiltrate the system prompt, invoke non-allowlisted capabilities, or repurpose the assistant.
- Authenticated-but-wrong-role user — a volunteer escalating to staff-only surfaces (PA broadcast, copilot, handover).
- Secret leakage — server credentials reaching the client bundle or logs.
Out of scope for the demo (documented, not ignored): multi-instance session revocation, persistent audit trails, DDoS beyond per-session rate limiting.
Every row names the code that enforces the control and the automated test that proves it. A hostile reviewer can run npm run test:coverage (unit) and npm run test:e2e (against the production build) to confirm each claim.
| Risk | Mitigation | Enforcing file(s) | Proving test |
|---|---|---|---|
| A01 Broken Access Control | RBAC decision table enforced in edge proxy AND per-route guards (one shared pure function so they cannot drift); pages redirect, APIs return 401/403. CSRF: state-changing POSTs are JSON-only and ride a SameSite=Lax httpOnly cookie, so cross-site forgery can't reuse a session. |
src/lib/auth/access.ts, src/proxy.ts, src/lib/auth/guard.ts, src/lib/auth/session.ts |
src/lib/auth/auth.test.ts (matrix); e2e/auth-rbac.spec.ts (anon→redirect/401 + volunteer→403 across all staff-only pages & APIs) |
| A02 Cryptographic Failures | HS256 JWT sessions (jose) with pinned iss/aud (a foreign-issuer token fails even with a leaked key); bcrypt password hashes; httpOnly + SameSite=Lax (+ Secure in prod) cookies |
src/lib/auth/session.ts, src/lib/auth/users.ts |
src/lib/auth/auth.test.ts (forged issuer/audience/role/missing-sub all → null) |
| A03 Injection / XSS | Every API body is a zod strictObject (unknown keys rejected); no SQL (no DB); no dangerouslySetInnerHTML anywhere — user & LLM output render as React text nodes, so HTML/JS is inert |
all src/app/api/**/route.ts, src/lib/api/request.ts, src/features/concierge/ConciergeChat.tsx |
e2e/*/unit body validation; ConciergeChat.test.tsx ("renders hostile model output as inert text") |
| A04 Insecure Design | Prompt-injection defenses in layers (below); deterministic mitigation/triage rules keep operational output auditable, not hallucinated; tool access is a code allowlist, not a prompt instruction | src/lib/ai/guards.ts, src/lib/ai/tools.ts, src/lib/ai/mitigation.ts, src/lib/ai/triage.ts |
src/lib/ai/guards.test.ts (7 adversarial payloads across 3 categories); src/lib/ai/tools.test.ts |
| A05 Security Misconfiguration | Strict CSP (same-origin everything, object-src 'none', frame-ancestors 'none'), X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-Frame-Options: DENY |
next.config.ts |
e2e/smoke.spec.ts ("security headers are served on every response") — asserts all five headers |
| A06 Vulnerable & Outdated Components | Small dependency surface (no UI kit, no chart lib); npm ci lockfile in CI; npm audit clean (0 vulnerabilities) — a transitive advisory in next's bundled postcss is patched by pinning postcss ^8.5.10 via overrides |
package.json (overrides), .github/workflows/ci.yml |
npm audit in CI; see "Dependency audit" below |
| A07 Identification & Auth Failures | Login throttled on two dimensions — 5/min/IP and 10/min/account; generic failure + dummy-hash compare on unknown email (no enumeration, no timing oracle); 8h session expiry | src/app/api/auth/login/route.ts, src/lib/rateLimit.ts, src/lib/auth/users.ts |
e2e/auth-rbac.spec.ts (identical 401 bodies; 429 + Retry-After under brute force); src/lib/rateLimit.test.ts |
| A08 Software & Data Integrity | CI gate: lint → typecheck → coverage-gated tests → build → client-bundle secret scan (demo fallback + live AUTH_SECRET/GEMINI_API_KEY) → e2e+axe |
.github/workflows/ci.yml, scripts/check-no-secrets.mjs |
npm run check:secrets (fails CI on any leak); see "Client-bundle secret scan" below |
| A09 Logging & Monitoring Failures | No secrets or PII logged; AI cache logs keys only in dev; raw provider errors never reach users (typed error codes → localized fallback copy) | src/app/api/ai/chat/route.ts, src/lib/ai/provider.ts, src/lib/api/request.ts |
ConciergeChat.test.tsx (error → generic bubble, no internals); typed-error unit tests |
| A10 SSRF | The server makes exactly one class of outbound call (Google GenAI SDK to a fixed endpoint); no user-supplied URL is ever fetched anywhere in the codebase | src/lib/ai/geminiProvider.ts |
Design invariant — grep for fetch(/http shows only the fixed SDK call and same-origin client calls |
Telemetry read endpoints (/api/telemetry/*, /api/announcements, /api/health) are intentionally public: they serve simulated, non-sensitive operational data that the no-login fan surfaces display. No authenticated or personal data is exposed by them.
npm audit reports 0 vulnerabilities. The only advisory that ever surfaced — GHSA-qx2v-qp2m-jg93 (postcss </style> XSS in CSS stringify, <8.5.10) — lived exclusively in the postcss@8.4.31 copy bundled inside next@16.2.10 (our own toolchain already used postcss@8.5.16). npm's only automatic fix was an absurd next@9 downgrade, so instead we pin "overrides": { "postcss": "^8.5.10" }, which dedupes next onto the patched build with no functional change (verified by the full test + build gate). The advisory concerns stringifying untrusted CSS to a browser — a path this app never exercises (PostCSS only processes our own authored Tailwind at build time) — so it was not exploitable here even before the pin.
npm run check:secrets greps every built client chunk under .next/static for the AUTH_SECRET dev fallback and the live values of AUTH_SECRET / GEMINI_API_KEY when set, and exits non-zero on any hit. Verified against the current production build:
stadiumiq-demo-fallback-secret-not-for-production-2026— 0 occurrences in the client bundle.- The strings
AUTH_SECRET,GEMINI_API_KEY, and any bcrypt hash ($2b$…) — absent from the client bundle. - Negative control: planting the fallback secret into a chunk makes the scanner exit
1and name the file — so a real leak would fail CI.
Note: the two demo login credentials are intentionally client-rendered on /login (documented below) — that is a deliberate evaluator affordance, not a secret leak. No server secret, key, or password hash ships to the browser.
The reviewer's required proofs map to these exact tests:
- (a) anonymous → /ops redirects, ops APIs → 401 —
e2e/auth-rbac.spec.ts: "every ops page redirects an anonymous visitor to login" (sweeps/ops,/ops/incidents,/ops/announcements,/ops/ask) and "every staff-only API returns 401 to an anonymous caller" (sweeps all four staff APIs). - (b) fan/non-staff session → 403 on staff APIs —
e2e/auth-rbac.spec.ts: "every staff-only API returns 403 to a volunteer session"; unit matrix insrc/lib/auth/auth.test.ts. - (c) rate limiters → 429 +
Retry-After— login:e2e/auth-rbac.spec.ts("login attempts are throttled per IP with Retry-After"); AI:e2e/concierge.spec.ts("AI endpoints rate-limit to 10 requests per minute"); unit:src/lib/rateLimit.test.ts. - (d) security headers present —
e2e/smoke.spec.tsasserts CSP,X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy. - (e) injection guard refuses ≥5 adversarial payloads —
src/lib/ai/guards.test.ts("adversarial refusal decision") covers instruction-override (incl. the literal "ignore all previous instructions"), role-play jailbreaks (DAN / developer mode) and reveal-system-prompt probes; end-to-end refusal ine2e/concierge.spec.ts(instruction-override and prompt-probe payloads).
- Input hygiene — user text is capped at 2,000 chars, control characters stripped, and
<user_input>delimiter escapes neutralized before anything else sees it (sanitizeUserText). - Detection & refusal before the provider — multilingual instruction-override patterns (
ignore previous instructions…,olvida tus instrucciones…, jailbreak/DAN phrasing) and system-prompt probes are answered with a polite canned refusal without ever calling a provider — true for both Mock and Gemini modes.guards.test.tspins attacks and benign look-alikes ("show me instructions to the prayer room" must pass). - System prompts pinned server-side — routes build them (
src/lib/ai/prompts.ts); the client cannot supply or read them. The assistant never reveals its instructions (tested). - Tool allowlist enforced in code, not prompt —
executeAiTooldispatches onlygetZoneOccupancy | getQueueStats | getTrend | getIncidents; anything else returns a typedtool-not-allowederror regardless of what any prompt says; arguments are zod-validated strict objects (src/lib/ai/tools.ts+ tests). - Structured outputs validated — every Gemini structured response is zod-parsed; on any failure the deterministic rule engine's output is used instead (triage, announcements), so the app never acts on malformed model output.
- Rate limiting — all
/api/ai/*routes: token bucket, 10 req/min per session (anonymous session cookie with IP fallback), 429 +Retry-After(unit + e2e tested).
- Demo credentials are public on the login page — intentional: the evaluator path must be one click. They exercise the real auth path (bcrypt compare, JWT, RBAC). Production: replace the two-user directory with a real IdP (OIDC) behind the same
SessionUserinterface. AUTH_SECRETdev fallback — with no env vars the app must boot (zero-config rule), so an obviously-named constant (stadiumiq-demo-fallback-secret-not-for-production-2026) signs sessions in dev. The secret scan proves it never ships in the client bundle; production MUST setAUTH_SECRET(32+ chars).- CSP includes
script-src 'unsafe-inline'— required by Next.js App Router bootstrap inline scripts without a nonce pipeline. Notably the CSP still blockseval: Lighthouse's only Best-Practices deduction is Chrome logging our CSP denying a dependency'sevalfast-path (which falls back safely) — the header doing its job. Production hardening: nonce-based CSP via the proxy. - In-memory rate limiting and stores — per-process by design for the demo. Production: Redis-backed token buckets and a real datastore behind the same interfaces (
src/lib/rateLimit.ts,src/lib/incidents/store.tsare already isolated behind functions). - HSTS is not set by the app: it must be added by the TLS-terminating host/proxy only over HTTPS (
Strict-Transport-Security: max-age=63072000; includeSubDomains).
Fans never authenticate; no personal data is collected or stored. Chat history lives only in the browser session's memory. Incident reports contain free text authored by staff/volunteers and are held in process memory only (lost on restart) — nothing persists.
Report vulnerabilities privately via GitHub Security Advisories — please don't open public issues for security reports. A machine-readable policy ships at /.well-known/security.txt (RFC 9116). Dependency posture: npm audit reports 0 vulnerabilities (re-checked in CI on every push); production deployments that boot without AUTH_SECRET log a one-line [security] error at startup so the demo fallback can never be silent.