Security

Threat model and operational security notes.

Modern DarkRP Control — Security & Threat Model

Applies to: Modern DarkRP Phases A–H, Anti-Cheat Phase 1, Productization P1.

Status: Phase H hardening applied; P1 action protocol + RBAC foundation (see PRODUCTIZATION_P1.md).

---

1. Assets

| Asset | Why it matters |

|---|---|

| Dashboard user accounts and sessions | Full administrative control over a live GMod server |

| Agent secrets (per game server) | Anyone holding one can impersonate the server and forge telemetry |

| Command queue | The only channel that mutates the live game server |

| Player data (SteamID64, names, positions, wallets, playtime) | Personal-ish data and competitive/economic information |

| Punishment records and audit log | Accountability record; must not be forgeable |

| Anti-Cheat detections and evidence | Basis for staff decisions; must not be spoofable |

| Configuration values | Changing them alters live gameplay economics |

| Database | Contains everything above |

2. Trust boundaries

[GMod client]  ── untrusted ──▶  [GMod server + Modern agent]
                                        │  HMAC-signed HTTPS
                                        ▼
[Internet]     ── untrusted ──▶  [Dashboard / API]  ──▶  [Database]
                                        ▲
[Browser]      ── semi-trusted ─────────┘  session cookie + RBAC

Boundary rules:

  1. The GMod client is never trusted. Everything a player can influence (position, speed, money deltas, entity spawns) arrives as agent telemetry and is treated as a claim, not a fact.
  2. The GMod server is authenticated, not trusted. A compromised or forged agent is contained by allowlists, payload limits, rate limits, and per-server isolation.
  3. The browser is authenticated and role-limited. Frontend visibility is a convenience; every API enforces its own permission check server-side.
  4. The database is fully trusted and must never be directly reachable from the internet.

3. Entry points

| Entry point | Auth | Hardening |

|---|---|---|

| POST /api/auth/login | none (credentials) | bcrypt cost 12, per-IP and per-username rate limits, generic error text |

| POST /api/auth/logout | session cookie | server-side session row deleted |

| /api/dashboard/** | session cookie + RBAC permission | origin check on mutations, server isolation, zod validation, rate limits on mutations/search |

| /api/servers, /api/servers/rotate-secret | session cookie + servers.manage | secret returned exactly once, never re-readable |

| /api/agent/v1/heartbeat | HMAC-SHA256 + secret | 60s timestamp window, replay rejection, 512 KB cap, per-server rate limit |

| /api/agent/v1/commands (GET) | HMAC-SHA256 + secret | claims are bounded (10) and time out |

| /api/agent/v1/ack | HMAC-SHA256 + secret | 64 KB cap, command must belong to authenticated server |

| /api/agent/v1/events (AC) | HMAC-SHA256 + secret | 512 KB cap, 100 events/batch, 8 KB payload budget, ingest rate limit |

| /api/agent/v1/gameplay | HMAC-SHA256 + secret | 512 KB cap, 100 events/batch, eventId uniqueness |

| /api/agent/v1/progression | HMAC-SHA256 + secret | 256 KB cap, 100 events/batch |

| /api/events/stream (SSE) | session cookie + server.view | per-server subscription, user-scoped notification events |

| /api/health, /api/health/ready | none | liveness/readiness only, no counts, sizes, versions, or credentials |

4. Attacker scenarios and mitigations

4.1 Unauthenticated internet user

Goal: read data or issue commands.

Mitigation: every /api/dashboard/** and /api/servers route calls requireUser()/requirePermission() before touching Prisma. Agent routes require a valid HMAC over the exact body. Health endpoints expose nothing sensitive.

Residual risk: the login endpoint is by definition reachable; rely on a strong owner password and the rate limiter.

Goal: act as that user.

Mitigation: cookies are HttpOnly (not readable by JS), SameSite=Lax, and Secure in production. CSP blocks injected external scripts. Sessions expire after 14 days and are deleted server-side on logout. Every action is written to the audit log with the actor.

Residual risk: an attacker with an active cookie has that user's permissions until logout/expiry. There is no "revoke all sessions" UI — see Known Limitations.

4.3 Low-privilege staff member

Goal: privilege escalation by calling APIs the UI hides.

Mitigation: RBAC is enforced in the route/service layer, not the component layer. enqueueAllowlistedCommand re-checks the per-command permission (players.ban, server.restart, config.edit, …) regardless of what the client sent. Staff role changes require OWNER/SUPERADMIN, cannot target OWNER accounts, and cannot be applied to yourself.

Residual risk: a MODERATOR can still perform every moderation action their role allows; scope roles carefully.

4.4 Malicious GMod player

Goal: fake economy/progression gain, or frame another player.

Mitigation: gameplay events are attributed by the authenticated server, deduplicated by (serverId, eventId), bounded per batch, and never grant dashboard privileges. Progression and economy figures are read-only reporting — the dashboard cannot mint currency or XP.

Residual risk: if a player exploits an addon on the game server, the dashboard faithfully reports the exploited numbers. Detection of in-game exploits is AC roadmap work.

4.5 Malicious GMod client (cheater)

Goal: evade AC Phase 1.

Mitigation: movement samples are evaluated server-side; detections are advisory only and never auto-punish. Evidence is stored for staff review.

Residual risk: AC Phase 1 covers movement heuristics only. This is intentional and AC Phase 2 is the follow-up.

4.6 Forged GMod agent

Goal: register fake telemetry or drain the command queue.

Mitigation: serverId is taken from the authenticated header and verified against the stored secret hash; the payload's own serverId is discarded. Without the secret, no HMAC can be produced.

Residual risk: none beyond secret compromise.

4.7 Leaked server secret

Goal: impersonate a server.

Mitigation: secrets are 256-bit random, stored only as SHA-256 hashes, never returned by any read API, never sent to the browser, never included in SSE payloads, never logged, and never echoed in errors. Rotation is supported live via POST /api/servers/rotate-secret with an overlap window (§ 5).

Residual risk: until rotation, a leaked secret allows forged telemetry for that one server. It grants no dashboard access and no cross-server access.

4.8 Replay attacker

Goal: resend a captured signed request.

Mitigation: every signed request carries a millisecond timestamp validated against a 60-second window, and the signature itself is consumed once per server (in-memory store, TTL = 2× window). Legitimate retries re-sign with a fresh timestamp and pass. Event ingestion is additionally idempotent on (serverId, eventId).

Residual risk: the replay store is per-process; a multi-instance deployment should use sticky routing or accept window-bounded replay of idempotent payloads.

4.9 Cross-server tenant attacker

Goal: read or command Server B while authorized for Server A.

Mitigation: every query is scoped by serverId derived from an authenticated context; unknown/foreign server IDs return 404. SSE subscriptions are per-server and events are published per-server. The Phase H smoke test asserts isolation across 16 resources plus SSE.

Residual risk: RBAC is currently global rather than per-server — a user with players.ban has it on every registered server. Documented as a limitation.

4.10 Malformed API client

Goal: crash the backend or bypass validation.

Mitigation: every mutating route parses input with zod (types, lengths, enums, numeric ranges, array bounds). Prisma is never handed a raw request object. Body sizes are capped before parsing. Errors are normalized — no stack traces, SQL, paths, or secrets reach the client.

4.11 Abusive authenticated staff member

Goal: mass-ban, spam restarts, or wipe entities.

Mitigation: mutations are rate limited per user (60/min default), destructive commands require explicit confirmation, bans are deduplicated while pending, and every action is audited with actor, action, target, server, and timestamp.

Residual risk: a legitimately privileged admin can still do legitimate damage; the audit log is the control.

5. Server secret handling

  • Generation: randomBytes(32).toString("base64url") (256 bits).
  • Storage: SHA-256("mdrp-agent:" + secret) only. Plaintext exists on the GMod server (data/modern_darkrp_control/config.json) and in the operator's hands.
  • Comparison: constant-time (timingSafeEqual) on both hashes and signatures.
  • Never: in API responses (except the one-time mint), SSE payloads, logs, audit metadata, error bodies, or browser storage.
  • Rotation with no downtime:

1. POST /api/servers/rotate-secret with { serverId, overlapMinutes } (default 60).

2. The new secret is returned once. The old hash moves to agentSecretPrevHash with an expiry.

3. Update data/modern_darkrp_control/config.json on the GMod server and reload the agent.

4. Confirm the heartbeat is green, then optionally re-run rotation with overlapMinutes: 0 to invalidate the old secret immediately.

  • Every rotation writes a server.secret_rotated audit entry containing the overlap window only — never the secret.

6. Command security

Commands are a closed allowlist (src/lib/commands/allowlist.ts):

ping · announce · kick · ban · unban · warn · map · restart · maintenance · entity_remove · entity_cleanup_player · entity_cleanup_class · config_apply

Verified absent: arbitrary Lua (RunString), arbitrary console/RCON commands, shell execution, filesystem access, arbitrary SQL, and Workshop installation. There is no endpoint that forwards a free-text string to the GMod console.

Validation is applied twice:

  • Dashboard side: a zod discriminated union per command type — map names match ^[a-zA-Z0-9_-]+$, announcements are ≤ 200 chars, entity cleanup classes come from CLEANUP_ALLOWED_CLASSES, config changes are checked against the registry's type and range.
  • GMod side: commands/sv_queue.lua re-checks the command type against its own allowlist and re-validates arguments before acting. An unknown type is acknowledged as failed, never executed.

Configuration apply cannot write secret-bearing keys; the registry contains no secret fields and the API states this explicitly.

7. RBAC

Permissions are checked server-side in every route. The audited surface: players, logs, commands, punishments, map, entities, staff, perks, progression, configuration, database, addons, system, notifications, search, and anti-cheat.

Notable rules:

  • Notifications are user-scoped: you can only read, mark, and dismiss your own.
  • Search filters each result group by the caller's permissions, so a MODERATOR searching does not see configuration or database results.
  • Detection review requires detections.review; module toggling requires modules.edit.
  • Role changes require OWNER/SUPERADMIN, and OWNER accounts are immutable via the API.

8. Transport and browser hardening

Set by src/middleware.ts on every non-static response:

| Header | Value |

|---|---|

| Content-Security-Policy | default-src 'self', frame-ancestors 'none', object-src 'none', no remote script origins |

| X-Content-Type-Options | nosniff |

| X-Frame-Options | DENY |

| Referrer-Policy | strict-origin-when-cross-origin |

| Permissions-Policy | camera, microphone, geolocation, payment all denied |

| Cross-Origin-Opener-Policy | same-origin |

| Strict-Transport-Security | 1 year + subdomains, only when served over HTTPS in production |

X-Powered-By is disabled. There is no permissive CORS: the API sets no Access-Control-Allow-Origin, so browsers block cross-origin reads by default. The GMod agent is a server-side HTTP client and does not need CORS.

CSRF: mutating requests are rejected unless the Origin/Referer host matches the request host or an entry in ALLOWED_ORIGINS. In production a missing Origin on a mutation is rejected outright; in development it is allowed so curl and the smoke scripts work. Agent routes are exempt because they authenticate with HMAC headers rather than cookies.

9. Logging and error handling

  • Errors return a normalized shape: a short message plus zod issue paths for validation failures. No stack traces, no SQL, no filesystem paths, no environment values.
  • The only server-side console.error is the unknown-error branch of handleRouteError, which logs the error object to the process log — not to the client.
  • Passwords, session tokens, agent secrets, database credentials, and authorization headers are never logged. Agent HTTP failures in Lua log the status code only, not the response body.
  • The audit log retains actor, action, target, server, timestamp, and result for every mutating operation.

10. Remaining risks (accepted for this release)

  1. Rate limiting, the replay store, and the SSE hub are per-process. A multi-instance deployment needs sticky sessions or shared state.
  2. RBAC is global, not per-server.
  3. No forced "log out all sessions" or password-change-invalidates-sessions flow.
  4. No MFA on dashboard accounts.
  5. The dashboard trusts the game server's reported telemetry; in-game exploits are reported faithfully.
  6. AC Phase 1 covers movement heuristics only and never auto-punishes. AC Phase 2 is the next roadmap item.
  7. x-forwarded-for is trusted for rate-limit identity; run behind a reverse proxy you control.