# left4me application hardening — threat model **Status:** living spec, intended input to a hardening implementation plan. Paired with `2026-05-15-hardening-defenses-survey.md` and `2026-05-15-hardening-test-plan.md`. > **Updated 2026-05-15:** `l4d2-sandbox` was collapsed into `left4me` > after the hardening refactor landed — see > `docs/superpowers/plans/2026-05-15-uid-collapse.md`. The same-uid > threat surface that doc accepts is the same surface this model > already documents for server/web; the sandbox is now in scope of > the same hardening profile. This document establishes *what we defend against and what we accept losing*. The defenses survey and test plan operationalize this against the codebase. ## Context The 2026-05-15 work landed deploy-dir-rethink + build-time-idmap and queued "uid split decision" as the next session's task (`2026-05-15-user-uid-split-design.md`). Audit of the running 2-user configuration found that the gameserver's systemd hardening blocks privilege escalation but leaves same-uid attack surface wide open: RCON passwords plaintext in `/var/lib/left4me/left4me.db` (readable by srcds), Flask `SECRET_KEY` in `/etc/left4me/web.env` (also readable), no ptrace block on `left4me-server@.service`, no `/proc` isolation. Rather than answer the original "1/2/3 uids" question in isolation, this work treats application hardening as a first-class refactor: ground the decision in an explicit threat model, survey the full Linux+systemd defense menu, test what composes safely with Source engine + the rest of the stack, then implement. ## Operating posture (assumed) Solo-operator, single-host infra (`left4.me` / `ovh.left4me`, 141.95.32.8). Host is a personal VPS, not multi-tenant. The only privileged operator is the user. There are no shell logins as `left4me` or `l4d2-sandbox`. All access to those uids is funneled through the systemd-managed units (`left4me-web.service`, `left4me-server@.service`, `left4me-script-sandbox`). The host runs nothing other than left4me + ckn-bw-managed baseline (nginx, sshd, fail2ban-class basics). If those assumptions don't hold (e.g., shared host with other tenants, non-systemd-mediated access to the uids), revise this document before proceeding — threat surface changes meaningfully. ## Assets Ordered by impact-if-compromised. Compromise means the attacker can exfiltrate, modify, or destroy the asset. ### Tier 1 — catastrophic, no easy recovery | Asset | Where | Impact of compromise | |---|---|---| | Host root | the box | Total compromise of every service on the host. | | `web.env` Flask `SECRET_KEY` | `/etc/left4me/web.env`, `root:left4me 0640` | Session forgery: attacker logs in as any admin without password. | | `web.env` Steam Web API key | same | Attacker can query/operate Steam Web API as us. Rate-limited; reputational. | | Server RCON passwords | DB: `Server.rcon_password` plaintext (`l4d2web/models.py:146-148`) | Attacker can execute arbitrary RCON on every gameserver: `sm_kick`, `rcon say`, server lockup, plugin abuse. | | User password hashes (bcrypt) | DB: `User.password_digest` (`l4d2web/models.py:31`) | Offline cracking per user. bcrypt slows it but doesn't stop it. | ### Tier 2 — severe but bounded | Asset | Where | Impact | |---|---|---| | `/opt/left4me/src/` Python source | `left4me:left4me` on disk | Persistent backdoor in web app via gunicorn reload. Currently RO from inside the server unit (`ProtectSystem=strict` covers `/opt`); RW from inside the web unit. | | Overlay content | `/var/lib/left4me/overlays//` | Persistent sourcemod plugin or replaced binary; surfaces in every gameserver using that overlay. | | Steam installation | `/var/lib/left4me/installation/` | Tampered `srcds_linux`; trivial persistence. Currently RO from server, RW from web. | | Sourcemod admin lists | inside overlays | RCON-equivalent: admin commands in-game. | | Workshop cache | `/var/lib/left4me/workshop_cache/` | Used by builds; tampered content surfaces in next overlay. | ### Tier 3 — limited, recoverable Job history, build logs, the small subset of in-game state not covered by the above (e.g., live player slot in a specific match). ## Trust boundaries Lines we want enforced. "Enforced" = the kernel + systemd, not "the process politely doesn't cross it." | Id | From | To | Strength today | Strength wanted | |---|---|---|---|---| | TB1 | External network | host shell | Strong (firewall, no extra services) | Strong | | TB2 | Gameserver process | rest of the host | Weak (same-uid + same-FS view) | Strong | | TB3 | Web app | rest of the host | Weak (same-uid + same-FS view) | Medium (sudo path inherent) | | TB4 | Sandbox | rest of the host | Strong (separate uid + hardened unit) | Strong | | TB5 | Gameserver instance N | gameserver instance M | None (same-uid, same-DB) | Strong | | TB6 | Web app | gameserver runtime state | None (same-uid, shared `runtime/` access) | Medium (web needs to stage server.cfg) | | TB7 | Gameserver | web-only secrets (DB, web.env) | None | Strong | | TB8 | Workshop content | srcds-process | Inherent (content runs as data) | n/a — not a software boundary | TB2, TB5, TB7 are the highest-leverage gaps. TB6 is partial because the web app legitimately writes per-instance config; the boundary is "web can write per-instance config" allowed, "web can ptrace srcds" denied. ## Attackers ### A1 — Anonymous external attacker (primary) Reaches public surfaces: - gunicorn on `:8000` (behind nginx + admin auth) - srcds on UDP `:27015`+ per instance (game protocol; no auth) - (Maybe: workshop subscription endpoints if any; check.) Capabilities: arbitrary network packets. Goal: code execution on the host, then exfiltrate secrets and persist. ### A2 — Authenticated admin (operator) In the assumed posture this is *the user*, single person. Out of scope as a threat per operator's choice (insider == operator). If admin auth ever expands to multiple operators, revise. ### A3 — Malicious workshop content A workshop addon (map, plugin, asset pack) is published to the Steam workshop and pulled into a build. The content runs inside srcds via Source engine + sourcemod loading. Capabilities: same as A1 once loaded into srcds (the engine doesn't have a strong privilege boundary against its own loaded plugins). Distinct in that the entry vector is curated by the operator (workshop link added to a blueprint), not arbitrary network input. Risk floor: the operator vetted the source. ### A4 — Compromised player session A connected player exploits a Source-engine protocol bug. Functionally a subset of A1 — same capability set once code is running in srcds. ### A5 — Local attacker on the host Out of scope per operating posture. No non-root local accounts beyond the systemd-managed service uids. ### A6 — Steam binary supply-chain `srcds_linux` is a binary from Valve. A compromised Valve build would already be running as `left4me` and there's no practical defense at this layer. Out of scope. ## Attack scenarios ### S1 — L4D2 engine RCE → exfil + persist A1 sends a crafted packet to srcds; srcds executes attacker code as `left4me` inside `left4me-server@.service`. **Today, attacker can:** - Read DB → all RCON passwords (plaintext), all bcrypt hashes. - Read `web.env` → SECRET_KEY, Steam API key. - ptrace gunicorn → in-memory secrets, current sessions. - Read `/proc//environ` → same env as `web.env`. - ptrace + read DB of peer `left4me-server@` — cross-server compromise. - `sudo left4me-systemctl|journalctl|overlay` for any instance. - Cannot write `/opt/left4me/src/` (ProtectSystem=strict covers `/opt`). - Cannot acquire new caps (NoNewPrivileges). **Defended outcome (goal):** Blast radius limited to "this gameserver's runtime state during this session" — no peer-server compromise, no DB access, no `web.env` access, no ptrace. ### S2 — Web app RCE → secrets + persistence A1 finds a Flask vulnerability (Jinja SSTI, SQLAlchemy injection, auth bypass, file-upload escape). Web executes attacker code as `left4me` inside `left4me-web.service`. **Today, attacker can:** - Read + write DB (web's primary path). - Read `web.env`. - Write `/opt/left4me/src/` → backdoor next gunicorn reload. - `sudo` all helper verbs. - ptrace srcds peers, modify their `runtime//` upper layer. - Modify overlays (writes to `/var/lib/left4me/overlays/`). **Defended outcome (goal):** Cannot ptrace gameservers; cannot read `/proc//*`; web compromise still owns its DB and env (its primary attack surface, so this is *acceptable residual*). ### S3 — Cross-server contamination S1 played out on srcds@1; attacker pivots to srcds@2. **Today:** trivial — ptrace srcds@2, read its memory; or just read the DB to learn srcds@2's RCON password and send commands. **Defended outcome (goal):** Blocked. Per-instance namespace isolation (or per-instance uid) means kernel rejects ptrace; DB invisible to gameserver uid hides the RCON list. ### S4 — Malicious workshop content A3 adds an addon to a blueprint; addon includes a Squirrel/SourceMod plugin that abuses engine APIs to do file I/O / network calls. **Today + with hardening:** functionally equivalent to S1 — the plugin runs as srcds, same blast radius. No software boundary prevents this; the only defense is what's outside the unit. So this is *covered* if S1 is covered. ### S5 — Sudoers helper abuse S1 or S2 attacker uses the sudo grants to widen access. **Today:** sudoers grants (audit findings, `deploy/files/etc/sudoers.d/left4me`): - `left4me-systemctl {enable|disable|show}` — any instance, no ownership check - `left4me-journalctl ` — read any unit's journal - `left4me-overlay mount|umount ` — any instance - `left4me-script-sandbox