catalog / codeberg.org/forgejo/forgejo
codeberg.org/forgejo/forgejo
validated pin current tags: 15
forgejo — minimum Linux capabilities, derived by drop-test against forgejo's own
default s6-init invocation (the deployment sets no cap_drop, so it runs on the
FULL Docker default cap set — this profile trims it 14 -> 6).
capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT].
The container runs TWO root daemons under s6, and the minimum is the UNION across both:
- `gitea web` — chowns a FRESH /data to the git user (CHOWN; DAC_OVERRIDE for the
ensuing setup writes, e.g. root rewriting the git-owned app.ini via
environment-to-ini) and drops root -> git (uid 1000) via su-exec (SETUID/SETGID).
- `sshd` — stays root, but OpenSSH forks a pre-auth privilege-separation child
that chroots per connection (SYS_CHROOT). Without it the web API still answers
200 while git-over-SSH is silently dead ("Connection reset") — so the workload
exercises SSH too. sshd binds the privileged :22, so it needs NET_BIND_SERVICE
— but ONLY under the hardened net.ipv4.ip_unprivileged_port_start=1024 posture
(see below). See the criteria doc's drop-test evidence + caveats.
NET_BIND_SERVICE is scoped to the hardened sysctl posture
net.ipv4.ip_unprivileged_port_start=1024 (the same posture httpd/traefik/nextcloud/
ntfy pin). Under Docker's default (0), ALL ports are unprivileged and sshd binds
:22 with no capability, so a posture-0 drop-test reads it FALSELY removable
(verified: at posture 0 sshd binds :22 under the 5-cap set; at 1024 the same set
fails "Bind to port 22 ... Permission denied"). The posture is recorded
structurally in run_config.sysctls (schema 1.4) and in the criteria doc. The
other 8 removed Docker
defaults (FSETID, FOWNER, MKNOD, NET_RAW, SETFCAP, SETPCAP, KILL, AUDIT_WRITE)
are genuine over-grants under this root-daemon invocation regardless of posture.
filesystem: not yet derived. Absence means not tested for this image —
not that it cannot run read-only.Use it
services:
forgejo:
image: codeberg.org/forgejo/forgejo:15
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT]
security_opt: ["no-new-privileges:true"]
Dimension: capabilities
cap_drop: ALL + cap_add: CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | codeberg.org/forgejo/forgejo@sha256:eda2e378442d… |
| validated date | 2026-07-16 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/forgejo.sh |
| workload sha256 | 359e92d61d1a… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
CHOWN | required | container exited: open /data/gitea/conf/app.ini: permission denied |
DAC_OVERRIDE | required | forgejo API /api/v1/version never returned 200 |
FSETID | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
FOWNER | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
MKNOD | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
NET_RAW | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
SETGID | required | container exited: su-exec: setgroups: Operation not permitted |
SETUID | required | container exited: su-exec: setuid(1000): Operation not permitted |
SETFCAP | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
SETPCAP | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
NET_BIND_SERVICE | required | under the pinned net.ipv4.ip_unprivileged_port_start=1024 posture, sshd fails to bind: 'Bind to port 22 on :: failed: Permission denied' (git-over-SSH dead). Posture-scoped: at Docker's default (0) it reads falsely removable |
SYS_CHROOT | required | sshd never reached auth (privsep failed -> git-over-SSH dead): Connection reset by ::1 port 22 |
KILL | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
AUDIT_WRITE | removable | correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| security_opt | no-new-privileges:true |
|---|---|
| sysctls | net.ipv4.ip_unprivileged_port_start=1024 |
| mounts | forgejo-data:/data |
| env | USER_UIDUSER_GIDFORGEJO__database__DB_TYPEFORGEJO__database__PATHFORGEJO__security__INSTALL_LOCKFORGEJO__server__ROOT_URL |
Workload script (profiles/workloads/forgejo.sh)
#!/usr/bin/env bash
# Workload exerciser for the forgejo reference image.
#
# The forgejo container runs two root daemons under s6 — `gitea web` (drops
# root -> git via su-exec) and `sshd` (root, with OpenSSH privilege separation) —
# and a faithful minimum must keep BOTH working. This drives a representative
# round-trip across the whole surface:
# - 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, commit a file through the contents API (a real
# server-side git write as the git user), then read the packfile path back
# with git ls-remote over smart HTTP;
# - sshd REACHES authentication: an unauthenticated connection returns
# "Permission denied (publickey)" only if the pre-auth privsep child forked
# (chroot + setuid) — the signal that git-over-SSH is alive.
# Returns 0 if every step succeeds. The non-root privilege-drop is asserted by the
# drop-test correctness check (see the criteria doc), not here.
#
# Probes exec as the git user (never root — a root probe's own needs would
# pollute the derived minimum) and write nothing outside forgejo's own store
# (no probe clone into /tmp: a probe temp file would false-require a tmpfs
# candidate if the filesystem dimension is ever derived).
#
# Required env: FORGEJOCONTAINER (target container name or id).
set -euo pipefail
: "${FORGEJOCONTAINER:?FORGEJOCONTAINER must be set}"
C="${FORGEJOCONTAINER}"
# Throwaway fixture credentials (ephemeral container, destroyed after the run).
U=csdprobe; P=csdprobe-pw-12345; E=csd@probe.local
deadline=$((SECONDS + 45))
until [ "$(docker exec --user git "$C" curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/v1/version 2>/dev/null)" = 200 ]; do
if (( SECONDS >= deadline )); then
echo "forgejo API /api/v1/version never returned 200" >&2
exit 1
fi
sleep 1
done
docker exec --user git "$C" /usr/local/bin/gitea admin user create \
--username "$U" --password "$P" --email "$E" --admin --must-change-password=false >/dev/null
rc="$(docker exec --user git "$C" curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" \
-H 'Content-Type: application/json' -d '{"name":"probe","auto_init":true}' \
http://localhost:3000/api/v1/user/repos)"
if [ "$rc" != 201 ]; then
echo "repo create via API returned HTTP $rc (expected 201)" >&2
exit 1
fi
# Server-side git write: the contents API makes forgejo itself commit the file
# (git plumbing runs as the git user inside the target — no client clone, no
# probe writes outside forgejo's own store).
rc="$(docker exec --user git "$C" curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" \
-X POST -H 'Content-Type: application/json' \
-d '{"content":"Y3NkLXByb2JlCg==","message":"probe"}' \
http://localhost:3000/api/v1/repos/$U/probe/contents/probe.txt)"
if [ "$rc" != 201 ]; then
echo "contents API commit returned HTTP $rc (expected 201)" >&2
exit 1
fi
# Smart-HTTP git read path (upload-pack advertisement), no on-disk checkout.
refs="$(docker exec --user git "$C" env GIT_TERMINAL_PROMPT=0 \
git ls-remote "http://$U:$P@localhost:3000/$U/probe.git" 2>&1)" || {
echo "git ls-remote over HTTP failed: ${refs##*$'\n'}" >&2
exit 1
}
if ! grep -q 'refs/heads/' <<<"$refs"; then
echo "ls-remote returned no heads (git smart HTTP broken)" >&2
exit 1
fi
sshout="$(docker exec --user git "$C" ssh -p 22 -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes \
-o ConnectTimeout=6 -o PreferredAuthentications=publickey git@localhost true 2>&1 || true)"
if ! grep -q 'Permission denied' <<<"$sshout"; then
echo "sshd never reached auth (privsep failed -> git-over-SSH dead): ${sshout##*$'\n'}" >&2
exit 1
fi
Dimension: filesystem
Not yet derived — this dimension has not been drop-tested for this image. Absence means not tested, not that a minimum here is infeasible.
Evidence & provenance
- Validation criteria — per-image scenarios and pass criteria
- Profile source in the repository
- Version history — every previously published pin, with the full profile as validated against it
Raw profile YAML
# forgejo — minimum Linux capabilities, derived by drop-test against forgejo's own
# default s6-init invocation (the deployment sets no cap_drop, so it runs on the
# FULL Docker default cap set — this profile trims it 14 -> 6).
#
# capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT].
# The container runs TWO root daemons under s6, and the minimum is the UNION across both:
# - `gitea web` — chowns a FRESH /data to the git user (CHOWN; DAC_OVERRIDE for the
# ensuing setup writes, e.g. root rewriting the git-owned app.ini via
# environment-to-ini) and drops root -> git (uid 1000) via su-exec (SETUID/SETGID).
# - `sshd` — stays root, but OpenSSH forks a pre-auth privilege-separation child
# that chroots per connection (SYS_CHROOT). Without it the web API still answers
# 200 while git-over-SSH is silently dead ("Connection reset") — so the workload
# exercises SSH too. sshd binds the privileged :22, so it needs NET_BIND_SERVICE
# — but ONLY under the hardened net.ipv4.ip_unprivileged_port_start=1024 posture
# (see below). See the criteria doc's drop-test evidence + caveats.
#
# NET_BIND_SERVICE is scoped to the hardened sysctl posture
# net.ipv4.ip_unprivileged_port_start=1024 (the same posture httpd/traefik/nextcloud/
# ntfy pin). Under Docker's default (0), ALL ports are unprivileged and sshd binds
# :22 with no capability, so a posture-0 drop-test reads it FALSELY removable
# (verified: at posture 0 sshd binds :22 under the 5-cap set; at 1024 the same set
# fails "Bind to port 22 ... Permission denied"). The posture is recorded
# structurally in run_config.sysctls (schema 1.4) and in the criteria doc. The
# other 8 removed Docker
# defaults (FSETID, FOWNER, MKNOD, NET_RAW, SETFCAP, SETPCAP, KILL, AUDIT_WRITE)
# are genuine over-grants under this root-daemon invocation regardless of posture.
#
# filesystem: not yet derived. Absence means not tested for this image —
# not that it cannot run read-only.
schema_version: "1.5"
image: codeberg.org/forgejo/forgejo
reference_url: https://tmatens.github.io/container-security-profiles/profiles/codeberg.org/forgejo/forgejo.html
applies_to:
tags: ["15"]
status: validated
dimensions:
capabilities:
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: codeberg.org/forgejo/forgejo@sha256:eda2e378442d2f18cfa563994f8ad66e71f04ac9c3bb4259cc57bdd641890f5c
validated_date: "2026-07-16"
duration_seconds: 83
confidence: high
workload: profiles/workloads/forgejo.sh
workload_sha256: "359e92d61d1abeebc3f087f705f156cc1ca5f577b5eb3878819f8fb5cea71ed6"
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: ""
pid: ""
devices: []
security_opt: ["no-new-privileges:true"]
sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
mounts: ["forgejo-data:/data"]
env: [USER_UID, USER_GID, FORGEJO__database__DB_TYPE, FORGEJO__database__PATH, FORGEJO__security__INSTALL_LOCK, FORGEJO__server__ROOT_URL]
drop_test:
checks:
- {removed: CHOWN, required: true, observed: "container exited: open /data/gitea/conf/app.ini: permission denied"}
- {removed: DAC_OVERRIDE, required: true, observed: "forgejo API /api/v1/version never returned 200"}
- {removed: FSETID, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: FOWNER, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: MKNOD, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: NET_RAW, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: SETGID, required: true, observed: "container exited: su-exec: setgroups: Operation not permitted"}
- {removed: SETUID, required: true, observed: "container exited: su-exec: setuid(1000): Operation not permitted"}
- {removed: SETFCAP, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: SETPCAP, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: NET_BIND_SERVICE, required: true, observed: "under the pinned net.ipv4.ip_unprivileged_port_start=1024 posture, sshd fails to bind: 'Bind to port 22 on :: failed: Permission denied' (git-over-SSH dead). Posture-scoped: at Docker's default (0) it reads falsely removable"}
- {removed: SYS_CHROOT, required: true, observed: "sshd never reached auth (privsep failed -> git-over-SSH dead): Connection reset by ::1 port 22"}
- {removed: KILL, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}
- {removed: AUDIT_WRITE, required: false, observed: "correct: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth"}