Threat model
Audience: people deploying or reviewing Moss. This document describes what Moss defends, against whom, and what it does not defend. It describes the code as it is; where a mitigation does not exist it says so. The internal security review lists findings and accepted risks. See also the Security model section of the README and architecture.md.
Summary
Moss is a controller for game servers on a host you own. It authenticates people (through Supabase), authorises them per server, and manages files, processes, downloads and backups on that host. It is not a sandbox: game servers, plugins, mods and the Forge/NeoForge/Quilt installers run as the same OS user as Moss. Anyone who can run a game server on Moss, or place a jar in one, can act as the Moss OS user. TLS is not terminated by Moss.
Assets
| Asset | Where | Why it matters |
|---|---|---|
| Server directories, worlds, configs | <data>/servers/ |
Player data; arbitrary code via jars |
| Backups | <data>/backups/ |
Full copies of the above |
| SQLite database | <data>/ (WAL) |
Users, grants, API token digests, webhook URLs, audit log, settings |
| Setup token | <data>/setup-token (0600) |
Proves host control to claim ownership of a fresh instance |
API tokens (moss_pat_...) |
Held by users; SHA-256 digest in DB | Programmatic access with the owner’s role |
| Supabase access tokens | Browser; transient in User.token |
Identity; forwarded to the CurseForge edge function |
| Webhook URLs | Database | Treated as secrets (Discord URLs embed a token) |
| Optional env secrets | SUPABASE_JWT_SECRET (legacy HS256), CURSEFORGE_API_KEY (Option B) |
Token forgery; CurseForge quota |
| The Moss OS user’s privileges | Host | Everything above, plus whatever else that user can reach |
Actors
| Actor | Trust |
|---|---|
| Unauthenticated network client | None |
| Authenticated user with a per-server grant (viewer, operator, manager) | Limited to granted servers and permissions (src/auth/mod.rs, bundle_perms) |
| Admin / owner | Can create servers, manage users, use the Custom runtime. Effectively able to run commands as the Moss user |
| API token holder | Owner’s current role, optionally read-only; cannot manage tokens or users |
| Server operator with file access | Can place jars and scripts that run as the Moss user once the server starts |
| Plugin / mod / game server process | Untrusted by intent, but not contained (same OS user) |
| Upstream providers (Mojang, PaperMC, Modrinth, CurseForge, Adoptium, maven hosts) | Trusted for metadata; payloads checked by hash where upstream publishes one |
| Supabase | Trusted for identity only |
| Reverse proxy | Trusted to terminate TLS and, if trust_proxy is set, to append the client address |
Trust boundaries
- Internet or LAN to the HTTP listener (through a TLS reverse proxy you provide).
- Moss to Supabase (JWKS fetch, edge function calls).
- Moss to upstream download hosts and provider APIs (outbound).
- Moss to webhook receivers (outbound, user-supplied URLs).
- Moss process to game server processes (same user; a weak boundary by design).
- Server directory to the rest of the host (enforced only by Moss’s own path checks, not by the OS).
Entry points
| Entry point | Credential | Code |
|---|---|---|
REST under /api/v1 |
Bearer Supabase JWT or moss_pat_ token |
src/api/mod.rs, src/auth/extract.rs |
/api/v1/events (SSE) and /api/v1/servers/{id}/console (WebSocket) |
Single-use ticket from POST /ticket |
take_ticket in src/api/mod.rs, src/api/session.rs |
/api/v1/setup/claim |
Verified JWT plus setup token | src/auth/setup.rs, Claimant extractor |
Static UI and /healthz |
None | src/api/assets.rs |
| File manager and uploads | files.read / files.write |
src/files.rs |
| Backup download and restore | backup.manage |
src/backups/ |
| Modpack and archive import | content.manage |
src/content/modpack/, src/backups/archive.rs |
| Outbound downloads | Server-side, triggered by users | src/download.rs |
| Outbound webhooks | Admin-configured URL | src/notify/webhook.rs |
| CurseForge edge function | User’s Supabase token | supabase/functions/curseforge/index.ts, src/providers/curseforge.rs |
| Game server processes and FIFO console | Local OS only | src/lifecycle/start.rs |
Threats and mitigations
STRIDE categories: S spoofing, T tampering, R repudiation, I information disclosure, D denial of service, E elevation of privilege.
HTTP API and authentication
| Threat | Cat. | Mitigation in code | Residual risk |
|---|---|---|---|
| Forged or replayed JWT | S | src/auth/jwt.rs: audience authenticated and issuer pinned to the Supabase URL; exp, sub, aud, iss required; 30 s leeway; asymmetric keys from JWKS (cached 10 min, forced refresh limited to once per 30 s, stale keys used up to 24 h during outages); HS256 only if SUPABASE_JWT_SECRET is set |
Stale JWKS means a revoked signing key stays trusted for up to 24 h during a Supabase outage. A leaked bearer token is valid until exp |
| Valid Supabase user who was never invited | E | Provisioning requires owner or invite and a verified email (src/auth/provision.rs, require_verified_email) |
Depends on Supabase email verification being configured correctly |
| Brute-force of tokens | S | Failed credentials counted per client address, 30 per 60 s, then 429 (src/auth/throttle.rs) |
In-memory only, resets on restart. Behind a proxy the address is the right-most X-Forwarded-For entry only when trust_proxy is on; wrong proxy configuration lets an attacker spoof or share an address |
| Stolen API token | S, E | Stored as SHA-256 digest with display prefix; shown once; expiry (fail closed on bad dates); read or full scope; cannot call token or user management; role is the owner’s current role; per-user cap (src/auth/tokens.rs, src/auth/extract.rs) |
A full-scope token is as powerful as its owner minus user/token management. No IP binding |
| First-comer claims ownership of a fresh instance | E | When no owner emails are configured, a random token is written to <data>/setup-token (0600); claim needs a verified JWT, the token (constant-time compare), is rate-limited (5 per 60 s), and one transaction ensures a single winner (src/auth/setup.rs, src/auth/secret.rs) |
Anyone who can read that file (same host, wrong permissions) can claim. Token file is kept until used |
| Missing second factor | S | MOSS_REQUIRE_MFA (off, admins, all) checks the aal2 claim (src/auth/mfa.rs) |
Default is off. API tokens bypass MFA by design |
| Dev auth mode exposed | E | Config::validate refuses dev mode on any non-loopback bind (src/config.rs); a startup warning is logged |
Any local process or user on the host, or a reverse proxy on the same host forwarding to loopback, gets full owner access. Never put a proxy in front of dev mode |
| Cross-server access (IDOR) | E | Every handler goes through server_for / authz with a named permission; invisible servers return not found (src/api/mod.rs, src/auth/mod.rs) |
Authorisation is per handler; a new route that forgets it would be open. Covered by review and tests, not by structure |
| Stream tokens in URLs leaking via logs or history; cross-site stream hijack | I | WebSocket and SSE use single-use tickets valid 30 s, same-host Origin required, capped at 2000 outstanding; the ticket carries the user, and the event stream filters by server visibility (src/api/session.rs, src/api/activity.rs, src/console.rs) |
A ticket is a bearer credential for 30 s; an observer of the URL within that window and before the legitimate client connects can use it. The WebSocket upgrade and the event stream reject a cross-origin Origin (src/api/origin.rs) before the ticket is consumed; requests without Origin are allowed |
| XSS or clickjacking in the UI | T, I | CSP (default-src 'self', script-src 'self', frame-ancestors 'none'), nosniff, no-referrer, X-Frame-Options: DENY (src/api/assets.rs, src/api/mod.rs); console and file content rendered as text |
style-src allows 'unsafe-inline'; connect-src allows any ws:/wss: host |
| Repudiation of actions | R | Audit log for console commands and file uploads, among others (st.audit); sensitive console commands redacted by default (src/console.rs) |
Audit rows live in the same database an admin can reach; no tamper evidence. Game-side actions by players are not audited |
| Request flooding | D | 512 MiB body limit (src/api/mod.rs); auth-failure throttle; ticket cap |
No general per-user rate limit, no request concurrency limit. Rely on the reverse proxy |
File manager, uploads and archives
| Threat | Cat. | Mitigation | Residual risk |
|---|---|---|---|
| Path traversal and symlink escape | T, I | safe_join rejects .., absolute paths, NUL, and any symlink component (lstat on each) (src/files.rs) |
Check-then-use is not atomic: a process that can create symlinks inside the server directory (the game server itself, a plugin) can race the check. The game server is not contained, so this boundary does not protect against it |
| Access to controller metadata | T, I | .moss/ is hidden from listings and refused for read, write, rename, delete, upload and download |
None known beyond the previous row |
| Stored XSS via uploaded files | T | Downloads send Content-Disposition: attachment; nosniff; uploads are never served inline |
None known |
| Malicious archive (zip slip, links, devices, bombs) | T, D | Backup archives: plain relative regular files and directories only, checksum verified first, extraction to staging, 2 TiB expansion cap (src/backups/archive.rs). Modpacks use the same rules |
Expansion cap is large; disk exhaustion is possible. Only tar.zst entry types are checked; content of jars is not scanned |
| Malicious jar or script uploaded by a manager | E | None. By design a manager with files.write and server.power can run code as the Moss user |
Not protected. Treat the manager bundle as code-execution on the host |
Outbound downloads
| Threat | Cat. | Mitigation | Residual risk |
|---|---|---|---|
| SSRF or download from an arbitrary host | T, I | HTTPS only; exact-domain or .domain suffix allow-list, re-checked on every redirect hop, 5 redirects max (src/download.rs); modpack file hosts limited to a second small list |
Allow-list includes github.com and githubusercontent.com, which serve user content. A malicious provider response can point at any file there |
| Tampered or substituted file | T | Streamed SHA-1/256/512 verification when upstream provides a hash; temp file then atomic rename (src/download.rs) |
Where no hash is published (some upstream metadata, CurseForge files without usable hash) integrity rests on TLS only. A compromised upstream publishes a matching hash |
| Oversized download | D | 4 GiB cap, connect and read timeouts | One server can still fill the disk with several downloads |
| Installer jars run third-party code | E | Forge, NeoForge and Quilt installers run with env_clear and a time limit (src/installer/modded.rs) |
Not sandboxed; they run as the Moss user |
Webhooks
| Threat | Cat. | Mitigation | Residual risk |
|---|---|---|---|
| SSRF to internal services | I, E | HTTPS only, no redirects, 5 s timeouts; destination resolved once, rejected unless globally routable (private, loopback, link-local, CGNAT, ULA, mapped, NAT64, 6to4 handled) and connected to by that IP (src/notify/webhook.rs) |
MOSS_ALLOW_PRIVATE_WEBHOOKS=true disables the check. HTTPS-only means plain-HTTP internal targets are unreachable regardless |
| Webhook URL leakage | I | URL never logged; errors strip it | Stored in the database in clear text; included in whatever backup of the database you take |
CurseForge edge function
| Threat | Cat. | Mitigation | Residual risk |
|---|---|---|---|
| API key exposure | I | Key lives only in the Supabase secret (or the local env in Option B); function requires a valid Supabase user token, fixed game id, class ids and endpoints, Cache-Control: no-store (supabase/functions/curseforge/index.ts) |
The function’s own limits are in memory per worker. Any signed-in Supabase user of the project can use it, not only Moss users |
| Open proxy | T | Allow-listed routes; download URL read from CurseForge’s file record, https on forgecdn.net hosts only, 512 MiB cap |
Terms-of-use questions about shared keys are the key holder’s, see curseforge.md |
| Quota exhaustion | D | Per-user and global limits, Moss-side limiter (src/providers/curseforge.rs) |
A coordinated set of users can still exhaust the shared quota |
Game server processes
| Threat | Cat. | Mitigation | Residual risk |
|---|---|---|---|
| Server or plugin reads or alters other servers, Moss data, or the database | I, T, E | None in-process. Launch uses an argument array and a cleared environment (src/lifecycle/start.rs); the shipped unit sets NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies (packaging/moss.service) |
Not protected. All servers share one OS user and filesystem view; one malicious plugin can read every other server, every backup, the database (including webhook URLs and token digests) and the setup token |
| Resource exhaustion by one server | D | Linux only: cgroup v2 memory, CPU and pids limits per server when the unit delegates a cgroup (src/platform/cgroup.rs) |
cgroups are for fairness and containment of runaway usage, not a security boundary. They do not restrict files, network or syscalls. Not enforced on macOS |
| Console command injection | E | Commands go through a FIFO as data; permission server.console.write required |
A console writer can run any in-game command, including those that execute code if plugins allow it |
| Custom runtime | E | Admin-only | Runs an admin-supplied command as the Moss user by definition |
| PID reuse hijack after restart | S | Re-attach by PID plus process start time (src/lifecycle/reconcile.rs, src/platform/) |
Same-user processes can still signal each other |
Explicitly not protected
- Isolation in process mode. No per-server OS user, no namespaces, no seccomp. Optional container isolation exists (see architecture.md) and shares the host kernel.
- A compromised Moss OS user. Everything Moss holds is readable by anything running as that user.
- TLS. Moss serves plain HTTP. You must provide a TLS reverse proxy for any non-loopback use, and the proxy must set the headers
trust_proxyexpects. - Dev auth mode on a shared host. It is loopback only, but loopback is reachable by every local user and process.
- Supabase compromise. Anyone who can mint valid Supabase tokens for your project and whose email is invited or owner gets in; the project itself is the root of identity.
- Supply chain of mods and plugins. Moss reads jars and checks compatibility and hashes, but does not scan for malware.
- Availability under abuse. Throttling is limited to failed authentication and tickets.
- Network egress control unless you opt in: process-mode servers can reach any address the host can, and container servers on the default
opennetwork can too. The containergame-onlynetwork (Docker only) blocks outbound connections to the internet; the host and other containers on the same bridge stay reachable.
Recommendations for deployers
- Run Moss as a dedicated unprivileged user with the shipped systemd unit.
- Terminate TLS in a reverse proxy; set
trust_proxyonly if the proxy is the sole path to Moss. - Never use
MOSS_AUTH=devanywhere except a single-user machine. - Enable
MOSS_REQUIRE_MFAfor admins at least. - Grant
managerand admin only to people you would trust with a shell as the Moss user. - Keep
<data>permissions at 0700 (Moss creates it that way when it does not exist) and back up the database separately from game data. - Add host-level controls (firewall egress, separate hosts per trust level) if you need more than the above.