# Security posture & threat model This document is the reference threat model for mcpelevator: what it protects, who the attackers are, where the trust boundaries sit, and how findings should be triaged by severity. For hands-on hardening steps see the **Security** section of the [README](../README.md) and, for internet exposure, [docs/claude-web-exposure.md](claude-web-exposure.md). ## 1. Overview mcpelevator is a self-hosted FastAPI + SvelteKit control plane that turns stdio or already-remote MCP servers into remote Streamable-HTTP endpoints. The backend serves a static SPA, control-plane APIs under `/api`, and a data-plane reverse proxy under `/s//...`. Desired state, settings, and token hashes are stored in SQLite; a supervisor starts one bridge subprocess per enabled server, each bound to a loopback port and wrapping a local command (`npx`, `uvx`, arbitrary command) or a remote HTTP/SSE upstream. The primary assets are: control-plane authority to create/enable servers and tokens, per-server data-plane bearer tokens, upstream header/env secrets, upstream OAuth tokens (stored per server at `/oauth/.json`, `0600`), the SQLite database, host/container filesystem and network access available to child MCP processes, and the integrity of Host/Origin/DNS-rebinding defenses. These upstream credentials — OAuth access/refresh tokens, any static `oauth_client_secret`, and header/`env` secrets — are stored **unencrypted at rest**, protected by filesystem permissions (`0600` token files, the DB file's own mode) rather than envelope encryption; see §3 *Credentials at rest*. By design, an authenticated admin can obtain local command execution and SSRF-like network access by adding a command or remote server. Therefore, "RCE" or "SSRF" is critical only when an unauthenticated, off-host, CSRF/DNS-rebinding, or improperly scoped caller reaches those capabilities. The security posture changes by deployment mode: loopback-only defaults to zero-config `/api`, LAN access can be enabled via `allow_private_lan` and forces admin auth under `auto`, and public/tunnel deployments should set `MCPE_PUBLIC_BASE_URL`, `bind_mode=expose`, allowed hosts, and per-server bearer or an external OAuth/access proxy. ## 2. Threat model, trust boundaries and assumptions **Attacker-controlled inputs:** HTTP requests to `/api`, `/s`, and static assets; `Host`, `Origin`, query/path/body, and bearer tokens; MCP client traffic proxied to bridges; catalog search/cursor parameters; data-plane long-lived SSE connections; untrusted output from child MCP processes shown in logs. **Operator-controlled inputs:** runtime settings (`bind_mode`, `allowed_hosts`, `default_auth_provider`, `control_plane_auth`, `allow_private_lan`), server launch specs (`runner`, `command`, `args`, `env`, `cwd`, `setup_script`, `auth_provider`, `enabled`), `mcpServers` imports, token creation/revocation, Docker/env configuration (`MCPE_ADMIN_TOKEN`, `MCPE_TRUSTED_PROXIES`, `MCPE_PUBLIC_BASE_URL`, etc.), and deployment topology. Operator/admin inputs are privileged: they may intentionally execute commands or proxy internal URLs. **External-but-untrusted inputs reviewed by an admin:** official registry and Glama catalog responses. The backend fetches only fixed registry hosts, then maps packages/remotes into install drafts that the admin reviews before `POST /api/servers`. **Developer/CI-controlled inputs:** tests, build scripts, Dockerfile and GitHub Actions. These affect supply-chain integrity, not runtime request authorization. **Trust assumptions:** There is no multi-user/RBAC boundary. A control-scope admin token is root-equivalent for the application. Data-plane tokens are shared secrets; an `all` token can use every bearer-protected server, while a server-id token must stay scoped to that server. Child MCP packages and remote upstreams may be malicious; mcpelevator gives them the container permissions and network available at runtime. Logs and environment variables are sensitive. A reverse proxy is trusted only if its real peer IP is in `MCPE_TRUSTED_PROXIES` or `MCPE_TRUST_DOCKER_HOST` is deliberately enabled; the code does not trust `X-Forwarded-For`. ## 3. Attack surface, mitigations and attacker stories **Control plane (`/api`).** Sensitive routers in `backend/app/main.py` depend on `require_control_plane` from `backend/app/auth/control_plane.py`; health, auth-status, and the upstream-OAuth redirect callback (`/api/oauth/callback`) remain public but are intentionally coarse. The callback is a top-level browser navigation from the upstream authorization server, so it carries no control token; it completes only a flow the operator already started, anchored on the unguessable OAuth `state` (an unknown/expired state is rejected), and stores tokens to the `0600` per-server file rather than the DB. The interactive grant (discovery, DCR, PKCE, exchange) runs in the control plane via the MCP SDK's `OAuthClientProvider`; each bridge only reads/refreshes the stored tokens and can never launch an interactive sign-in. Enforcement is `always`, or `auto` when `bind_mode=expose`, `MCPE_PUBLIC_BASE_URL`/`MCPE_ALLOWED_HOSTS` declares an off-host origin, or `allow_private_lan` is enabled. A serious attacker story is an off-host request creating an enabled `command` server or minting tokens without a control token. Relevant controls include settings lockout checks (`would_lock_out`), requirement that the current request already authenticate before enabling auth/exposure, protection against revoking the last control token while enforced, and idempotent first-boot token minting. **Host/Origin and DNS rebinding.** `backend/app/auth/middleware.py` is the edge guard for both `/api` and `/s`: Host is mandatory, Origin is checked when present, loopback hostnames are accepted only from loopback or configured trusted proxy peers, and LAN mode accepts only private-IP literals from private-network peers. It deliberately does not resolve hostnames to private IPs, preventing classic DNS rebinding with `evil.example`. Trusted proxies are evaluated from the TCP peer, not `X-Forwarded-For`; trusted forwarders are excluded from the LAN peer gate. Misconfiguring Docker by publishing `0.0.0.0:8080` while keeping gateway trust is a high-risk operator error documented in `docker-compose.yml` and docs. **Data plane (`/s//...`).** `backend/app/proxy/router.py` looks up the slug, calls the same `enforce()` chokepoint, then streams to the loopback bridge, stripping hop-by-hop headers and content encoding. Bearer tokens are SHA-256-hashed at rest and matched by hash lookup rather than plaintext comparison. There is no data-plane break-glass credential — `MCPE_ADMIN_TOKEN` is accepted only on the control plane (where it is compared with `secrets.compare_digest`), never as a `/s/` token. Data tokens with `scope=all` or the matching `server.id` are accepted, while `control` tokens are not valid data-plane tokens. Unknown auth providers fail closed and API schemas restrict provider values. A critical bug would be resolving `bearer` or `inherit` to `none`, accepting a single-server token for another server, or allowing `Host: localhost` from a remote peer. A server explicitly set to `none` is not itself a vulnerability when the operator intentionally relies on loopback, LAN trust, or an external OAuth/Access gate; it is dangerous if exposed directly to the internet. **Credentials at rest.** Upstream credentials are stored **unencrypted** on the control-plane host. Upstream OAuth access/refresh tokens and the discovered client registration live in the per-server `/oauth/.json` files (created `0600`, atomic replace). A static `oauth_client_secret` the operator supplies is stored in the SQLite `server` row alongside upstream header/`env` secrets — so **SQLite backups contain OAuth client secrets**, not just data-plane config — and, once a sign-in completes, that secret is also copied into the client registration in the JSON file. mcpelevator does **not** encrypt any of these at rest — it relies on operating-system file permissions and control of the data directory. The one exception is data-plane and admin **bearer tokens**, which are SHA-256-hashed and never stored in the clear (§ Data plane). Consequently, anyone who can read the data directory — host root, a compromised control-plane process, an unprotected volume mount, or a copied backup — can read every upstream credential. This is a deliberate tradeoff: the tokens must be readable by the headless bridge subprocess so it can call and refresh the upstream, and a single-container self-hosted deployment has no shared secret manager to delegate to. Static analysis (CodeQL `py/clear-text-storage-sensitive-data`) flags this storage; it is a **known, accepted** finding, not a regression. Mitigate at the deployment layer: keep the data directory and any backups on encrypted, access-controlled storage, don't mount it into untrusted containers, and revoke a leaked grant at the provider (and via **Disconnect**, which clears the file store) rather than relying on on-disk secrecy. **Command execution and supervision.** `backend/app/api/servers.py`, `backend/app/registry/service.py`, `backend/app/runners/`, `backend/app/supervisor/unit.py`, and `backend/app/bridge/host.py` turn stored specs into child processes. Local runners pass argv lists, not shell strings, and the supervisor executes the bridge module with `asyncio.create_subprocess_exec`. The exception is the optional `setup_script` for `npx`, `uvx`, and `command` servers. Before every startup attempt it runs through `/bin/sh -e -c` as the mcpelevator process user, with the local MCP child's initial effective environment and working directory. Shell-local state does not reach the child, but files and other external effects remain according to their storage location, so setup must be safe to run again. Docker setup belongs in the image; remote servers have no local setup. `MCPE_APT_PACKAGES` is separate global container bootstrap, not a per-server hook. Setup scripts are stored as unencrypted server configuration in SQLite. Their combined output enters the authenticated server log stream. Both may contain credentials or other sensitive data and should be handled like `env` secrets. The official container currently runs mcpelevator and setup as root. Files outside mounted or otherwise persistent paths, including system changes made by setup, disappear on container replacement; only persistent paths and package caches configured on them survive. The **docker runner** is opt-in and root-equivalent: it is disabled by default behind the `docker_runner` setting, enforced at the service layer (a docker server can't be created-enabled or enabled while it's off) and again in the supervisor (reconcile refuses to start a docker unit while it's off). The catalog gate is separate — OCI installs are surfaced non-installable until the runner is enabled. When enabled it stores the canonical image+args+env shape and synthesizes a hardened `docker run` (`--rm --init --cap-drop ALL --security-opt no-new-privileges --pids-limit` + a memory cap). Secrets are passed by **name** (`-e KEY`), never `KEY=value`, so a value never enters mcpelevator's own argv/`ps` (Docker still resolves it into the container config, which anyone with Docker daemon access can read via `docker inspect` — name-only passing narrows exposure to daemon-holders, it doesn't hide the value from them); and the bridge gives a docker child a **minimal env** (only `PATH/HOME/DOCKER_*` plus the server's declared vars), so a `-e KEY` passthrough can never reach the control plane's own secrets (`MCPE_ADMIN_TOKEN`, DB creds); a container also can't set a `DOCKER_*`/reserved env name — nor a **Go proxy var** (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`/…) — as a container env (rejected at create and stripped at launch): either would retarget or alter the CLI's own daemon request (a proxy var would reroute the control-plane's daemon API call on a TCP `DOCKER_HOST`/dind). Those proxy vars are also NOT inherited from the operator's env into the CLI, so an operator `HTTP_PROXY` (set for npm/uv) can't break the CLI→dind connection. (To proxy a launched container, use the Docker CLI **config with `proxies`** in the mcpelevator container — a Docker CLI behavior outside mcpelevator's env handling; avoid it if launched images are untrusted, since Docker injects those proxy vars into every launched container.) Each container carries an `mcpelevator.server=` label (Docker assigns the name) — that label is the sole cleanup handle: the unit reaps its containers by label on stop (fire-and-forget `docker rm -f`, outside the stop grace) and the supervisor sweeps labelled orphans (scoped to this instance's server ids) on boot — the residual gap is a container left by a hard control-plane crash with a wedged daemon. Isolation is a deployment choice (sibling host socket vs. an isolated dind sidecar via `DOCKER_HOST`); the sibling model hands the host daemon to containers and is inherently root-equivalent, which is why the runner is opt-in. Imports create disabled servers by default; catalog installs (including OCI) are reviewable drafts gated by the same setting. Resource note: `max_running` bounds concurrent server *units*, not containers — the proxy opens a fresh upstream per client session, so a single docker server can spawn one memory-capped (`--memory`) container per concurrent session. This is the same fresh-session model every runner uses (docker just makes each spawn a heavier container); bound it by requiring `bearer` auth on docker servers so only authorized clients can open sessions, and by not exposing untrusted images to unauthenticated `/s` traffic. `max_running`, bounded port allocation, process groups, readiness probes, and log buffers improve robustness. Remaining risks are expected admin power and supply-chain risk: a malicious MCP package runs arbitrary code with the process user's filesystem/network access. It does **not**, however, *inherit* the control plane's own secrets: the bridge starts every local-exec child (`npx`/`uvx`/`command`) and its `setup_script` with the `MCPE_*` namespace scrubbed from the environment (`bridge.host._child_env` / `ServerUnit._effective_child_env`), and keeps the admin token out of the bridge parent's own env (`ServerUnit._bridge_env`), so `MCPE_ADMIN_TOKEN` and other elevator config are not passed to an untrusted package nor recoverable from its immediate parent's `/proc//environ` — matching the docker runner's minimal-env guarantee. This env boundary, not the registry's best-effort shell-wrapped-docker detection, is the primary in-process mitigation for a config that shells out to the docker CLI; that string analysis is bounded defense-in-depth (the shell is Turing-complete, so it can never be complete). **Residual (same-UID `/proc`).** Env scrubbing is not a hard boundary on a same-UID host: the control-plane process was `exec`'d by the operator *with* any `MCPE_ADMIN_TOKEN` in its environment, so that value stays in the process's own `/proc//environ` (deleting it from `os.environ` does not clear the kernel's exec-time region), which a same-UID child can read by walking `PPid`. A hard guarantee requires OS-level isolation — the **docker runner**, running children under a **separate UID / PID namespace**, or mounting `/proc` with **`hidepid=2`** — or supplying the admin token through a non-environment channel. Secrets a server legitimately needs go in its own `env`; ambient non-`MCPE_` deployment vars are still inherited, so avoid placing unrelated credentials in the control plane's environment when untrusted local-exec servers are enabled. **Remote runner and catalog.** Remote server URLs are validated as `http(s)` with a hostname and canonical transport, but there is no egress allowlist or metadata-IP block. That is acceptable for admins but would be critical if unauthenticated users could create/enable remote servers. Catalog API calls use fixed upstream base URLs, quoted path segments, 15s timeouts, capped TTL cache, and deleted/unsupported registry entries are blocked or marked manual. Untrusted registry data should never be auto-executed without the normal admin review path. **Frontend and tokens.** The SPA stores the admin token in `localStorage` and sends it as an Authorization header, so CSRF via cookies is mostly out of scope, but XSS would steal the admin token. Svelte escaping and no obvious raw HTML reduce XSS risk; logs from untrusted processes must continue to be rendered as text. There is no rate limiting; high-entropy tokens make guessing impractical, but exposed deployments should add outer rate limits. **CI/build.** The Docker image includes Node/npx and uv/uvx by design. GitHub Actions pins third-party actions, requires semver release tags, emits SBOM/provenance, and runs Trivy/Hadolint. Review build-context hygiene as well: without a `.dockerignore`, dirty local `.env`, `.venv`, or `node_modules` content can enter developer-built images if present. These are supply-chain controls, separate from runtime authorization. ## 4. Criticality calibration **Critical:** unauthenticated or wrong-scope access to state-changing `/api` endpoints that can create/enable commands, remote URLs, settings, or tokens; Host/Origin bypass allowing off-host `Host: localhost` or DNS rebinding to loopback; data-plane auth bypass where bearer-protected servers accept no token or the wrong server scope; any path from untrusted registry/catalog data to enabled command execution without admin review. **High:** public/LAN request incorrectly considered loopback/private because of trusted-proxy logic; `control_plane_auth=auto` failing to enforce when an off-host origin or LAN mode is active; leakage of admin/control tokens via API responses, static assets, logs beyond the documented one-time bootstrap, or XSS; ability to delete/reset the last admin credential while enforcement remains on; header injection causing remote runner to send attacker-chosen upstream auth outside admin intent. **Medium:** denial of service through many long-lived data-plane streams, large request bodies, repeated server starts, or catalog cache churn; information disclosure from public health endpoints beyond coarse readiness; untrusted child logs exposing secrets to any admin; weak validation that causes bridge endpoints beyond `/mcp` to be reachable; lack of rate limiting for token attempts despite high entropy. **Low:** misconfiguration footguns already documented (wrong Docker port publishing, leaving `none` auth on an exposed server); UI lockout or confusing copy URLs; dependency/CI issues that do not permit runtime auth bypass; token hashes using unsalted SHA-256 for high-entropy generated tokens, which is acceptable but should not be reused for human passwords; upstream credentials (OAuth tokens/refresh tokens in the `0600` token files; static client secrets and header/`env` secrets in the SQLite row) stored unencrypted at rest, protected by filesystem permissions and data-directory control rather than encryption — a deliberate tradeoff and the reason the CodeQL `py/clear-text-storage-sensitive-data` alert is a known, accepted finding (§3 *Credentials at rest*).