Build and supply chain

Forgejo Actions runner isolation, the secure way

A CI runner is a machine whose whole job is to run code that strangers send it in pull requests. Said like that, it deserves more isolation than the default install and a hopeful label called "docker".

The short answer

Run each Forgejo runner on its own VM per trust level, with rootless or user-namespaced Docker. Use only docker:// labels, never host. Keep privileged off, docker_host at "-" and valid_volumes empty. Drop capabilities, set no-new-privileges and memory and process limits, run one job at a time, and firewall egress and cloud metadata.

Updated Houssam Hammoudi, CTOTested with forgejo-runner v13.2.0, Docker 28, alpine:3.22 job image

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A Forgejo runner takes jobs from the forge and runs their steps. Anyone who can push a branch, or open a pull request where fork workflows are allowed, chooses what those steps do. The runner's isolation is the only thing between that code and:

  • the runner host, and every other job running on it,
  • the runner's registration secret and the forge,
  • the network behind the runner, including cloud metadata endpoints.

Three settings undo that isolation completely: a host label (steps run directly on the machine), privileged: true, and a Docker socket mounted into jobs. Several popular setups turn on one of them to make Docker builds work.

What the docs say

Forgejo Runner performs remote code execution. That poses significant security threats for the host and network that it operates upon.

Source: Forgejo docs, Forgejo Actions administrator guide

There is no isolation at all and a single job can permanently destroy the host.

Source: Forgejo docs, Runner configuration

If container.privileged is configured to true and our attacker Mallory is able to mutate actions workflows that are executed, Mallory will be able to operate as root on the Forgejo Runner machine; all confidential data can be compromised, all data integrity can be compromised, and availability of the service can be disrupted.

Source: Forgejo docs, Securing Forgejo Actions deployments

Depending on the criticality and confidentiality of other services and data that are shared on the Forgejo Runner's host, you may wish to completely isolate Forgejo Runner in its own virtual machine.

Source: Forgejo docs, Docker access

The Forgejo docs are unusually clear about the risks. What they leave to you is the combination: the daemon defaults are safe, but a local test with forgejo-runner exec behaves differently (see below), and container defaults still give a job root with fourteen capabilities and no limits.

The secure configuration

The host

  • One VM per trust level: public or fork pull requests on one runner, trusted branches on another, release and deploy jobs on a third. Never share.
  • Docker in rootless mode for the runner user, or with user namespace remapping, so root in a job is not root on the VM.
  • Replace VMs regularly (or per job for untrusted work); do not let state build up.
  • Firewall egress: allow the forge, your registry mirror and package mirrors; block everything else, including 169.254.169.254 (cloud metadata).

config.yml

yaml
log:
  level: info
  job_level: info
runner:
  file: .runner            # holds the registration secret: mode 0600, owned by the runner user
  capacity: 1              # one job at a time on this VM
  timeout: 1h
  labels:
    # docker:// only. Never "host", "self-hosted:host" or "lxc" for untrusted code.
    - "docker:docker://registry.example.com/ci/node@sha256:<digest>"
container:
  network: ""              # a new network for each job
  privileged: false
  # Resource limits and hardening for every job container.
  options: "--memory=2g --cpus=2 --pids-limit=512 --cap-drop=ALL --security-opt=no-new-privileges"
  valid_volumes: []        # jobs cannot mount host paths or named volumes
  docker_host: "-"         # no Docker socket inside jobs
  force_pull: true         # do not trust a cached image a previous job may have replaced

The test below set these job options with forgejo-runner exec flags. After you deploy the daemon config, run the probe workflow from this page once on each runner and check the output matches.

Jobs that install packages as root (apt-get, apk add) need a few capabilities back. Add them for that runner label only: --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=SETUID --cap-add=SETGID. Image builds use kaniko in a normal job, not a Docker socket.

On the forge

  • Register runners per organization or repository, not instance-wide, so a runner only takes jobs from the code it is meant for.
  • Review workflow changes in pull requests from forks before they run on any runner, and route fork pull requests to the untrusted runner only.
  • Secrets are available only to jobs on trusted branches, never to fork pull requests.

Prove it

A probe workflow that prints what a job can see:

yaml
on: [push]
jobs:
  probe:
    runs-on: docker
    steps:
      - name: what can a job see
        shell: sh
        run: |
          id -u
          grep -E '^(CapEff|NoNewPrivs):' /proc/self/status
          ls -l /var/run/docker.sock 2>&1 || true
          echo "memory.max: $(cat /sys/fs/cgroup/memory.max)"
          echo "pids.max: $(cat /sys/fs/cgroup/pids.max)"

The daemon's generated defaults are safe:

bash
forgejo-runner generate-config | grep -E "privileged|docker_host|valid_volumes|  network"
text
  network: ""
  privileged: false
  valid_volumes: []
  docker_host: "-"

forgejo-runner exec, which people use to try workflows locally, takes its job settings from command-line flags. With its defaults, the job gets the Docker socket, fourteen capabilities and no limits:

bash
forgejo-runner exec -i alpine:3.22
text
| 0
| CapEff:	00000000a80425fb
| NoNewPrivs:	0
| srw-rw----    1 root     118              0 Sep 24 17:16 /var/run/docker.sock
| memory.max: max
| pids.max: 7098
Job succeeded

With the hardened options:

bash
forgejo-runner exec -i alpine:3.22 --container-daemon-socket - --container-cap-drop ALL \
    --container-opts "--memory=256m --pids-limit=128 --security-opt=no-new-privileges"
text
| 0
| CapEff:	0000000000000000
| NoNewPrivs:	1
| ls: /var/run/docker.sock: No such file or directory
| memory.max: 268435456
| pids.max: 128
Job succeeded

Still uid 0 inside the container, which is why the host runs Docker rootless or with user namespaces: that root maps to an unprivileged user on the VM.

Mistakes people make

The host label "just for this one job"

A host or self-hosted:host label runs steps as the runner user on the VM, with access to the runner's registration file. One such label on a shared runner makes every other protection on it irrelevant.

Mounting the socket to build images

docker_host: automount or a socket in valid_volumes gives every job the Docker daemon, which is root on the VM. Build images with kaniko or another daemonless builder.

Testing with exec and trusting the result

forgejo-runner exec mounts the socket by default, unlike the daemon. A workflow that works under exec may rely on access the real runner will not give, and the reverse. Test on a real runner with the probe.

One runner for everything

A runner that takes fork pull requests and release jobs lets a pull request leave something behind (a poisoned cache, a modified image) for the release. Separate runners per trust level, on separate VMs.

Open egress and metadata

A job that can reach 169.254.169.254 on a cloud VM can often read cloud credentials. Block it on the VM firewall, along with every destination the jobs do not need.

Checklist

  • Each runner VM serves one trust level only.
  • Docker on the runner VM is rootless or uses user namespace remapping.
  • Labels are docker:// only, with images pinned by digest; no host labels.
  • privileged: false, docker_host: "-", valid_volumes: [].
  • container.options sets memory, CPU and process limits, drops capabilities and sets no-new-privileges.
  • capacity: 1 on runners that take untrusted jobs.
  • The probe workflow was run on each runner after deployment and the output checked.
  • Egress is limited to the forge and mirrors; cloud metadata is blocked.
  • Fork pull request workflows run only on the untrusted runner and never receive secrets.
  • Runner VMs are replaced on a schedule.

The runner will run whatever it is given; that is its job. Your job is to decide how small the room is where it does that.

H2-CSDE

Learn it on a live range

Self-hosting the forge, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us