Skip to content

Security: yawarquil/StadiumIQ

Security

SECURITY.md

Security

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.

Threat model

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:

  1. Anonymous web attacker — probing APIs, brute-forcing login, spamming AI endpoints, XSS/clickjacking attempts.
  2. 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.
  3. Authenticated-but-wrong-role user — a volunteer escalating to staff-only surfaces (PA broadcast, copilot, handover).
  4. 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.

OWASP Top 10 (2021) → mitigation → enforcing file → proving test

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.

Dependency audit (A06)

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.

Client-bundle secret scan (A08)

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-20260 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 1 and 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.

Verification checklist (security review)

The reviewer's required proofs map to these exact tests:

  • (a) anonymous → /ops redirects, ops APIs → 401e2e/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 APIse2e/auth-rbac.spec.ts: "every staff-only API returns 403 to a volunteer session"; unit matrix in src/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 presente2e/smoke.spec.ts asserts CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy.
  • (e) injection guard refuses ≥5 adversarial payloadssrc/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 in e2e/concierge.spec.ts (instruction-override and prompt-probe payloads).

Prompt-injection defenses (layered, unit-tested)

  1. 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).
  2. 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.ts pins attacks and benign look-alikes ("show me instructions to the prayer room" must pass).
  3. 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).
  4. Tool allowlist enforced in code, not promptexecuteAiTool dispatches only getZoneOccupancy | getQueueStats | getTrend | getIncidents; anything else returns a typed tool-not-allowed error regardless of what any prompt says; arguments are zod-validated strict objects (src/lib/ai/tools.ts + tests).
  5. 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.
  6. 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).

Deliberate demo decisions (and their production paths)

  • 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 SessionUser interface.
  • AUTH_SECRET dev 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 set AUTH_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 blocks eval: Lighthouse's only Best-Practices deduction is Chrome logging our CSP denying a dependency's eval fast-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.ts are 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).

Privacy

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.

Vulnerability disclosure

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.

There aren't any published security advisories