collin/anvil
RenderedSource
| 1 | # Threat model: running anvil with untrusted users |
| 2 | |
| 3 | anvil is built as a **single-tenant, owner-operated forge**: the operator and |
| 4 | the people with accounts are assumed to trust each other (a person, a family, a |
| 5 | small team). This document records what would have to be true before opening |
| 6 | registration (or repo write access) to people you *don't* trust, ranked by |
| 7 | severity. It is the output of the security-audit pass; keep it updated as |
| 8 | items land. |
| 9 | |
| 10 | **Supported stance:** single-tenant / owner-operated. Untrusted multi-tenancy |
| 11 | is *not* supported until at least items 1–4 below are closed. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## 1. CI: arbitrary code execution by design |
| 16 | |
| 17 | Anyone who can push to a repo with a `.anvil/ci.yml` runs arbitrary code on |
| 18 | your hardware. That is the *point* of CI, so the question is only how well the |
| 19 | blast radius is contained. |
| 20 | |
| 21 | **Broker model (implemented).** anvil itself is the only Docker client. The |
| 22 | job container gets: |
| 23 | |
| 24 | - **no Docker socket, no bind mounts, no volumes** — the checkout is uploaded |
| 25 | into the container as a tar via the Docker API, so the job can never reach |
| 26 | anvil's data directory or the host filesystem; |
| 27 | - **`--cap-drop=ALL` and `no-new-privileges`** unconditionally; |
| 28 | - **pids / memory(+swap) / cpu caps** (`ci.pids_limit`, `ci.memory_mb`, |
| 29 | `ci.cpus`; defaults 512 / 2048 MiB / 2); |
| 30 | - **a wall-clock timeout** (`ci.timeout_secs`, default 30 minutes) after which |
| 31 | the container is force-removed; |
| 32 | - optionally **no network** (`ci.network = false`) and a **non-root user** |
| 33 | (`ci.run_as = "1000:1000"`) — most real builds need network and many base |
| 34 | images assume root, so these default to permissive; |
| 35 | - an **image allowlist** (`ci.allowed_images`) — empty allows any image, which |
| 36 | is fine single-tenant; set it before letting strangers push. |
| 37 | |
| 38 | **Deliberately not done:** read-only rootfs (the workspace lives in the |
| 39 | container filesystem precisely so no volume is ever attached; builds also |
| 40 | write `$HOME` caches), and egress *filtering* (network is all-or-nothing). |
| 41 | |
| 42 | **Residual risk / stronger tier.** Containers share the host kernel; a kernel |
| 43 | or runc escape defeats all of the above. For genuinely hostile tenants run the |
| 44 | jobs under gVisor/Kata/Firecracker (a runtime flag on the broker — the |
| 45 | "isolated workers" idea), and add per-user CI-minute and disk quotas (image |
| 46 | pulls consume host disk). Until then, CI for untrusted users should stay off. |
| 47 | |
| 48 | ## 2. Stored XSS via served content |
| 49 | |
| 50 | Repo browsing renders escaped text (Maud auto-escapes; highlighting emits |
| 51 | sanitized HTML), so hostile file *content* does not execute in the forge |
| 52 | origin today. |
| 53 | |
| 54 | **Pages hosting is the exception by design**: it serves attacker-authored |
| 55 | HTML/JS. On a single-origin deployment, a pages site runs in the same origin |
| 56 | as the forge UI — its JS could read forge pages and drive authenticated |
| 57 | requests in a visitor's session. Mitigations in place: session cookies are |
| 58 | `HttpOnly` (no token theft) and all mutating routes require the CSRF token. |
| 59 | But same-origin JS can still *read* the token off a fetched page, so for |
| 60 | untrusted users pages must move to a **separate origin** (e.g. |
| 61 | `*.pages.example.com`), as GitHub does. The same applies to any future "raw |
| 62 | blob" endpoint: serve `text/plain` + `nosniff` + a restrictive CSP, or a |
| 63 | separate origin. |
| 64 | |
| 65 | ## 3. Git resource exhaustion |
| 66 | |
| 67 | A hostile pusher can send decompression bombs (tiny pack, enormous objects), |
| 68 | deep delta chains, or millions of refs; a hostile cloner can request expensive |
| 69 | packs repeatedly. Needed before untrusted use: per-repo and per-user storage |
| 70 | quotas, an upload size cap on `receive-pack`, timeouts/memory bounds on pack |
| 71 | ingestion and pack generation, and a cap on advertised refs. (The CI tar |
| 72 | materializer also loads a full checkout into memory — bounded today only by |
| 73 | push quotas not existing.) |
| 74 | |
| 75 | ## 4. Open registration anti-abuse |
| 76 | |
| 77 | Registration is currently operator-controlled (CLI), which is the real |
| 78 | mitigation. Opening it requires: email verification, rate limiting on signup / |
| 79 | login / repo creation, a CAPTCHA or proof-of-work, per-user quotas (repos, |
| 80 | storage, CI minutes), and an admin ban/cleanup path. Reserved usernames and |
| 81 | the `/-/` route namespace already prevent route-shadowing squats. |
| 82 | |
| 83 | ## 5. Authorization granularity |
| 84 | |
| 85 | Access today is owner-or-admin, repo public-or-private. Fine single-tenant; |
| 86 | multi-user collaboration needs collaborator roles (read/write/admin per repo), |
| 87 | per-repo deploy keys, and scoped tokens instead of full-account SSH keys. The |
| 88 | deploy webhook is already scoped to exactly one configured repo. |
| 89 | |
| 90 | ## 6. Webhook SSRF (future) |
| 91 | |
| 92 | User-configurable webhooks don't exist yet (the deploy webhook URL is |
| 93 | operator-set in the config file, not user data). When they land: resolve and |
| 94 | block private/link-local/metadata ranges (and re-check on redirect), pin DNS, |
| 95 | cap response sizes and time, and never reflect response bodies to users. |
| 96 | |
| 97 | --- |
| 98 | |
| 99 | ## Already right (keep it that way) |
| 100 | |
| 101 | - Private repos 404 for non-readers — no existence leak (`resolve_repo`). |
| 102 | - Reserved usernames + `/-/` namespace for app routes. |
| 103 | - CI checkout is tar-uploaded, never bind-mounted; job containers get no |
| 104 | socket; sandbox defaults are on (see §1). |
| 105 | - CD webhook gated to a single configured repo + shared-secret header. |
| 106 | - Passwords: argon2. Sessions: `HttpOnly` + `SameSite=Lax` + `Secure` (auto |
| 107 | when `base_url` is https). CSRF: HMAC synchronizer token, constant-time |
| 108 | compare, on all mutating forms; htmx requests carry it via `hx-headers`. |
| 109 | - SSH auth by exact public-key match; unknown keys rejected. |