Verify release write-token confinement on the Forgejo runner (gates 1.0.4 auto-updates) #24
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: needs validation (deployment check, no severity). Found by security audit run 4 (commit
77fcd83).Fingerprint:
release.yml:write-token-confinement-assumes-isolated-runnerRelated: #18, #19
Why
The #18 stance (floating action tags, no
composer.lock) is acceptable only if no job other than the third-party-freereleasejob can publish. That depends on two premises that the repo cannot show.permissions: contents: read(.forgejo/workflows/release.yml:23) for the automatic token on tag pushes. The comment at:19-22says that if it is ignored, every job gets the default token.CLAUDE.md:29records that wp-env started from a CI job spawned containers on that daemon.If either premise fails, a compromised
setup-php@v2release, or a freshly resolved composer dev dependency, could replacemulti-domain-redux.<v>.zip. #19 makes opted-in sites install that asset automatically. The first release delivered that way is 1.0.4, to 1.0.3 sites that have opted in.Check
Use a throwaway repo on the same instance and runner. It publishes nothing real.
permissions: contents: readdoes three things:DOCKER_HOST,/var/run/docker.sockanddocker version;permissionskey, for comparison.Outcome / fix
container.docker_host: "-", not privileged, with a pinned image.contents: read: pinsetup-phpto a SHA and commitcomposer.lockfor tag builds, or move the gates to a separate workflow with no token.Check run 2026-10-16: both premises fail
Setup. Throwaway private repo
eric/runner-checkon Forgejo 17.0.0 (gitea-1.22.0 API), same runner,runs-on: ubuntu-latest(imagedocker.gitea.com/runner-images:ubuntu-latest). One tag push (0.0.1) triggered two workflows:restricted.yml, with top-levelpermissions: contents: read(run 329, job 655);default.yml, with no permissions key (run 328, job 654).The probes printed only variable names and HTTP status codes. No token values were logged.
contents: readpermissionsPOST /releases(draft) withsecrets.GITHUB_TOKENDELETE204)DOCKER_HOSTtcp://host.docker.internal:2375GET /versionover that address/var/run/docker.sockCapEff00000000a80425fb(default docker set, not privileged)ACTIONS_RUNTIME_TOKEN,ACTIONS_ID_TOKEN_REQUEST_TOKENpresentWhat this means
permissions: contents: readis not enforced on this instance. Every job's automatic token can create releases. Inrelease.ymlthat includesstaticand the threeunitjobs, which runsetup-php@v2(floating tag) and freshly resolved composer dev dependencies. Any of that code can upload or replacemulti-domain-redux.<v>.zip. The "write only in the third-party-free release job" confinement from #18 does not hold.docker execinto, or tamper with, the release job and its image. This applies toci.ymltoo, so any branch push or PR runs with that access.Either point alone is enough for a compromised upstream action or composer package to ship a tampered release. With #19, 1.0.3 sites that opt in would then install it automatically from 1.0.4 onward.
Suggested fixes (runner/instance config + workflow)
Daemon: set
container.docker_host: "-"in the runnerconfig.ymlso jobs don't getDOCKER_HOST. Better still, run release jobs on a dedicated runner or label whose jobs have no daemon access. Integration tests already stay out of CI, so nothing needs the daemon from inside a job.Token: don't rely on
permissionson this Forgejo version. Make the gate jobs token-free instead:setup-phpto a commit SHA and commitcomposer.lock, orRe-check whether a newer Forgejo enforces
permissions.Defence in depth: publish a detached signature or SHA-256 that
UpdateCheckerverifies against a key CI does not hold. That makes a replaced asset detectable no matter what the runner exposes.Re-run this probe after changes: the workflows are in
eric/runner-check.1.0.3 itself is installed by hand, so it can ship. Fix this before tagging 1.0.4.
Results (probe on eric/runner-check, same runner)
Before (runs 328/329,
0.0.1):permissions: contents: readignored: draft release created (201) in both the restricted and default jobs.DOCKER_HOST=tcp://host.docker.internal:2375injected via runnercontainer.options,/version→ 200, no TLS.Fixes
f282a09):container.options: "",docker_host: "-", unprivileged jobs,valid_volumes: []; DinD on its default entrypoint withDOCKER_TLS_CERTDIR=/certs(2376,--tlsverify), client certs only in the runner container.91a21b7): everyuses:pinned to a commit SHA,composer.lockcommitted (platform PHP 8.3.0), CLAUDE.md #18 stance revised. Needed because Forgejo still hands every job a token that can publish.After (
reprobe-2):DOCKER_HOST, no/var/run/docker.sock, no/certsin the job.host.docker.internal,docker,dind, 172.17.0.1.docker versionrefused.Daemon not reachable from jobs. Token remains publish-capable (Forgejo limitation); the SHA pins + lockfile are the control for that.