forgejo — validation criteria
Per-image acceptance criteria for the codeberg.org/forgejo/forgejo profile (the
self-hosted Git forge). Validated against …@sha256:55bb42be… (tag 15), derived
by drop-test against forgejo's own default s6-init invocation. The deployment
sets no cap_drop, so forgejo runs on the full Docker default cap set; this
profile trims it 14 → 5.
Representative workload / correctness check
profiles/workloads/forgejo.sh drives the whole surface, because the container runs
two root daemons under s6 and a faithful minimum must keep both alive:
- web/API up (/api/v1/version);
- a real DB + git write: create an admin user (sqlite write as the git user),
create a repo via the API, then clone + commit + push over HTTP;
- sshd reaches authentication: an unauthenticated SSH connection returns
Permission denied (publickey) only if the pre-auth privilege-separation child
forked (chroot + setuid). The drop-test correctness check additionally asserts the
gitea web process runs as the non-root git user (uid 1000) — a health check
alone is not enough (forgejo can serve /api/v1/version while failing to drop
root or with git-over-SSH broken).
capabilities — derived by drop-test
- cap_drop: [ALL], cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT].
Baseline
cap_drop:ALL+ the Docker default set on a fresh/datavolume (the image declares/dataas a VOLUME, so each trial gets a fresh, root-owned volume — reproducing a first deploy, so the root→git chown fires); each default is dropped in turn and the workload re-verified. Five are startup caps — runtime observation records them as unused, so drop-test is the authoritative source — and NET_BIND_SERVICE gates sshd's:22bind under the hardened port posture (below). - The minimum is the UNION across both daemons.
gitea webneeds CHOWN (chown the fresh/datato the git user), DAC_OVERRIDE (setup writes it doesn't own as root — e.g.environment-to-inirewriting the git-ownedapp.ini), and SETUID/SETGID (thesu-execroot→git drop).sshdneeds SYS_CHROOT for the per-connection privsep chroot. - SYS_CHROOT is the workload-coverage sharp edge. Drop it and the web API still
answers 200 and the git-over-HTTP path still works — but every SSH connection is
reset (
Connection reset), so git-over-SSH is silently dead. A web-only correctness check would derive a 4-cap minimum that breaks SSH. Because the deployment publishes2222:22for Git SSH, the profile keeps SYS_CHROOT and the workload exercises SSH. - NET_BIND_SERVICE gates sshd's
:22bind, but only under the hardened port posture. sshd binds the privileged:22; being root is not sufficient — undercap_drop:ALLa uid-0 process has an empty effective set and, whennet.ipv4.ip_unprivileged_port_startis the hardened1024, cannot bind a port below it. Verified directly against the pinned image: with the 5-cap set (no NET_BIND_SERVICE) sshd binds:22at Docker's default posture (0, where all ports are unprivileged) but at1024failsBind to port 22 on :: failed: Permission deniedand git-over-SSH is dead. So NET_BIND_SERVICE reads falsely removable in a posture-0 drop-test and is genuinely required under the hardened posture — the same posturehttpd/traefik/nextcloud/ntfypin. The profile therefore includes it and scopes it tonet.ipv4.ip_unprivileged_port_start=1024, recorded structurally inderivation.run_config.sysctls(schema 1.4). The web listener binds the unprivileged:3000and needs no capability at any posture. - Pass criteria: the workload passes (web + git-over-HTTP + sshd-reaches-auth)
and
gitea webruns as uid 1000; dropping CHOWN, DAC_OVERRIDE, SETUID, SETGID, or SYS_CHROOT breaks correctness with the signatures recorded indrop_test.checks.
Scope (run_config + out-of-band conditions)
- Invocation (
derivation.run_config): forgejo's — root (nouser:override, the image drops privileges itself), theforgejo-data:/datavolume,USER_UID/USER_GID=1000, sqlite3,INSTALL_LOCK=true,no-new-privileges. Run with an already-initialised, git-owned/datathe chown is a no-op and CHOWN reads as removable — this profile is the first-deploy conservative superset. Disabling the built-in SSH server (DISABLE_SSH=true) removes the SYS_CHROOT requirement. - Out of band (not schema fields): Docker's default cap set + default seccomp
baseline; a fresh
/datavolume; the built-in sshd enabled; amd64. The minimum is only valid for whatprofiles/workloads/forgejo.shexercises. - Coverage caveats: KILL reads as removable — s6 signals its cross-uid children
(the git-owned
gitea web) on shutdown/restart, a path the running-service workload does not exercise; it is not needed for steady-state operation. LFS, external-DB (DB_TYPE=postgres), and mailer paths are not exercised.