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

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagecodeberg.org/forgejo/forgejo@sha256:eda2e378442d…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/forgejo.sh
workload sha256359e92d61d1a…
ig versionv0.51.0

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
CHOWNrequiredcontainer exited: open /data/gitea/conf/app.ini: permission denied
DAC_OVERRIDErequiredforgejo API /api/v1/version never returned 200
FSETIDremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
FOWNERremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
MKNODremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
NET_RAWremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
SETGIDrequiredcontainer exited: su-exec: setgroups: Operation not permitted
SETUIDrequiredcontainer exited: su-exec: setuid(1000): Operation not permitted
SETFCAPremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
SETPCAPremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
NET_BIND_SERVICErequiredunder 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_CHROOTrequiredsshd never reached auth (privsep failed -> git-over-SSH dead): Connection reset by ::1 port 22
KILLremovablecorrect: web+API up, gitea uid=1000 (non-root), admin+repo+git-push over HTTP, sshd reaches auth
AUDIT_WRITEremovablecorrect: 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_optno-new-privileges:true
sysctlsnet.ipv4.ip_unprivileged_port_start=1024
mountsforgejo-data:/data
envUSER_UID
USER_GID
FORGEJO__database__DB_TYPE
FORGEJO__database__PATH
FORGEJO__security__INSTALL_LOCK
FORGEJO__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

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"}