anvilsign in

collin/anvil

RenderedSource

1# Threat model: running anvil with untrusted users
2
3anvil is built as a **single-tenant, owner-operated forge**: the operator and
4the people with accounts are assumed to trust each other (a person, a family, a
5small team). This document records what would have to be true before opening
6registration (or repo write access) to people you *don't* trust, ranked by
7severity. It is the output of the security-audit pass; keep it updated as
8items land.
9
10**Supported stance:** single-tenant / owner-operated. Untrusted multi-tenancy
11is *not* supported until at least items 1–4 below are closed.
12
13---
14
15## 1. CI: arbitrary code execution by design
16
17Anyone who can push to a repo with a `.anvil/ci.yml` runs arbitrary code on
18your hardware. That is the *point* of CI, so the question is only how well the
19blast radius is contained.
20
21**Broker model (implemented).** anvil itself is the only Docker client. The
22job 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
39container filesystem precisely so no volume is ever attached; builds also
40write `$HOME` caches), and egress *filtering* (network is all-or-nothing).
41
42**Residual risk / stronger tier.** Containers share the host kernel; a kernel
43or runc escape defeats all of the above. For genuinely hostile tenants run the
44jobs 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
46pulls consume host disk). Until then, CI for untrusted users should stay off.
47
48## 2. Stored XSS via served content
49
50Repo browsing renders escaped text (Maud auto-escapes; highlighting emits
51sanitized HTML), so hostile file *content* does not execute in the forge
52origin today.
53
54**Pages hosting is the exception by design**: it serves attacker-authored
55HTML/JS. On a single-origin deployment, a pages site runs in the same origin
56as the forge UI — its JS could read forge pages and drive authenticated
57requests in a visitor's session. Mitigations in place: session cookies are
58`HttpOnly` (no token theft) and all mutating routes require the CSRF token.
59But same-origin JS can still *read* the token off a fetched page, so for
60untrusted users pages must move to a **separate origin** (e.g.
61`*.pages.example.com`), as GitHub does. The same applies to any future "raw
62blob" endpoint: serve `text/plain` + `nosniff` + a restrictive CSP, or a
63separate origin.
64
65## 3. Git resource exhaustion
66
67A hostile pusher can send decompression bombs (tiny pack, enormous objects),
68deep delta chains, or millions of refs; a hostile cloner can request expensive
69packs repeatedly. Needed before untrusted use: per-repo and per-user storage
70quotas, an upload size cap on `receive-pack`, timeouts/memory bounds on pack
71ingestion and pack generation, and a cap on advertised refs. (The CI tar
72materializer also loads a full checkout into memory — bounded today only by
73push quotas not existing.)
74
75## 4. Open registration anti-abuse
76
77Registration is currently operator-controlled (CLI), which is the real
78mitigation. Opening it requires: email verification, rate limiting on signup /
79login / repo creation, a CAPTCHA or proof-of-work, per-user quotas (repos,
80storage, CI minutes), and an admin ban/cleanup path. Reserved usernames and
81the `/-/` route namespace already prevent route-shadowing squats.
82
83## 5. Authorization granularity
84
85Access today is owner-or-admin, repo public-or-private. Fine single-tenant;
86multi-user collaboration needs collaborator roles (read/write/admin per repo),
87per-repo deploy keys, and scoped tokens instead of full-account SSH keys. The
88deploy webhook is already scoped to exactly one configured repo.
89
90## 6. Webhook SSRF (future)
91
92User-configurable webhooks don't exist yet (the deploy webhook URL is
93operator-set in the config file, not user data). When they land: resolve and
94block private/link-local/metadata ranges (and re-check on redirect), pin DNS,
95cap 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.