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**Secrets in a pipeline.** A run can request repository secrets (see
39[secrets.md](secrets.md)), which arrive as environment variables inside that
40same container. Anyone who can push to the repo can therefore read every secret
41it declares, by editing the pipeline; log masking stops accidents, not intent.
42The compensating control is that anvil cannot decrypt them at all unless the
43owner has unlocked the repo, so the exposure window is bounded by the unlock
44TTL rather than being permanent.
45
46**Deliberately not done:** read-only rootfs (the workspace lives in the
47container filesystem precisely so no volume is ever attached; builds also
48write `$HOME` caches), and egress *filtering* (network is all-or-nothing).
49
50**Residual risk / stronger tier.** Containers share the host kernel; a kernel
51or runc escape defeats all of the above. For genuinely hostile tenants run the
52jobs under gVisor/Kata/Firecracker (a runtime flag on the broker — the
53"isolated workers" idea), and add per-user CI-minute and disk quotas (image
54pulls consume host disk). Until then, CI for untrusted users should stay off.
55
56## 2. Stored XSS via served content
57
58Repo browsing renders escaped text (Maud auto-escapes; highlighting emits
59sanitized HTML), so hostile file *content* does not execute in the forge
60origin today.
61
62**Pages hosting is the exception by design**: it serves attacker-authored
63HTML/JS. On a single-origin deployment, a pages site runs in the same origin
64as the forge UI — its JS could read forge pages and drive authenticated
65requests in a visitor's session. Mitigations in place: session cookies are
66`HttpOnly` (no token theft) and all mutating routes require the CSRF token.
67But same-origin JS can still *read* the token off a fetched page, so for
68untrusted users pages must move to a **separate origin** (e.g.
69`*.pages.example.com`), as GitHub does. The same applies to any future "raw
70blob" endpoint: serve `text/plain` + `nosniff` + a restrictive CSP, or a
71separate origin.
72
73## 3. Git resource exhaustion
74
75A hostile pusher can send decompression bombs (tiny pack, enormous objects),
76deep delta chains, or millions of refs; a hostile cloner can request expensive
77packs repeatedly. Needed before untrusted use: per-repo and per-user storage
78quotas, an upload size cap on `receive-pack`, timeouts/memory bounds on pack
79ingestion and pack generation, and a cap on advertised refs. (The CI tar
80materializer also loads a full checkout into memory — bounded today only by
81push quotas not existing.)
82
83## 4. Open registration anti-abuse
84
85Registration is currently operator-controlled (CLI), which is the real
86mitigation. Opening it requires: email verification, rate limiting on signup /
87login / repo creation, a CAPTCHA or proof-of-work, per-user quotas (repos,
88storage, CI minutes), and an admin ban/cleanup path. Reserved usernames and
89the `/-/` route namespace already prevent route-shadowing squats.
90
91## 5. Authorization granularity
92
93Access today is owner-or-admin, repo public-or-private. Fine single-tenant;
94multi-user collaboration needs collaborator roles (read/write/admin per repo),
95per-repo deploy keys, and scoped tokens instead of full-account SSH keys. The
96deploy webhook is already scoped to exactly one configured repo.
97
98## 6. Webhook SSRF (future)
99
100User-configurable webhooks don't exist yet (the deploy webhook URL is
101operator-set in the config file, not user data). When they land: resolve and
102block private/link-local/metadata ranges (and re-check on redirect), pin DNS,
103cap response sizes and time, and never reflect response bodies to users.
104
105---
106
107## Already right (keep it that way)
108
109- Private repos 404 for non-readers — no existence leak (`resolve_repo`).
110- Reserved usernames + `/-/` namespace for app routes.
111- CI checkout is tar-uploaded, never bind-mounted; job containers get no
112 socket; sandbox defaults are on (see §1).
113- CD webhook gated to a single configured repo + shared-secret header.
114- Passwords: argon2. Sessions: `HttpOnly` + `SameSite=Lax` + `Secure` (auto
115 when `base_url` is https). CSRF: HMAC synchronizer token, constant-time
116 compare, on all mutating forms; htmx requests carry it via `hx-headers`.
117- SSH auth by exact public-key match; unknown keys rejected.