Verify release write-token confinement on the Forgejo runner (gates 1.0.4 auto-updates) #24

Closed
opened 2026-10-16 14:43:16 -07:00 by eric · 2 comments
Owner

Type: needs validation (deployment check, no severity). Found by security audit run 4 (commit 77fcd83).
Fingerprint: release.yml:write-token-confinement-assumes-isolated-runner
Related: #18, #19

Why

The #18 stance (floating action tags, no composer.lock) is acceptable only if no job other than the third-party-free release job can publish. That depends on two premises that the repo cannot show.

  1. Permission enforcement. Forgejo enforces permissions: contents: read (.forgejo/workflows/release.yml:23) for the automatic token on tag pushes. The comment at :19-22 says that if it is ignored, every job gets the default token.
  2. Job isolation. Gate jobs can't reach the shared DinD daemon that the release job runs on. CLAUDE.md:29 records that wp-env started from a CI job spawned containers on that daemon.

If either premise fails, a compromised setup-php@v2 release, or a freshly resolved composer dev dependency, could replace multi-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.

  • A tag-push job with permissions: contents: read does three things:
    • lists its environment variable names;
    • probes DOCKER_HOST, /var/run/docker.sock and docker version;
    • tries to create a draft release with its token.
  • A second job tests the same with no permissions key, for comparison.

Outcome / fix

  • Both blocked: close this issue.
  • Daemon reachable: run the release job on a runner or label with container.docker_host: "-", not privileged, with a pinned image.
  • Write allowed under contents: read: pin setup-php to a SHA and commit composer.lock for tag builds, or move the gates to a separate workflow with no token.
**Type:** needs validation (deployment check, no severity). Found by security audit run 4 (commit `77fcd83`). **Fingerprint:** `release.yml:write-token-confinement-assumes-isolated-runner` **Related:** #18, #19 ## Why The #18 stance (floating action tags, no `composer.lock`) is acceptable only if no job other than the third-party-free `release` job can publish. That depends on two premises that the repo cannot show. 1. **Permission enforcement.** Forgejo enforces `permissions: contents: read` (`.forgejo/workflows/release.yml:23`) for the automatic token on tag pushes. The comment at `:19-22` says that if it is ignored, every job gets the default token. 2. **Job isolation.** Gate jobs can't reach the shared DinD daemon that the release job runs on. `CLAUDE.md:29` records that wp-env started from a CI job spawned containers on that daemon. If either premise fails, a compromised `setup-php@v2` release, or a freshly resolved composer dev dependency, could replace `multi-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. - A tag-push job with `permissions: contents: read` does three things: - lists its environment variable **names**; - probes `DOCKER_HOST`, `/var/run/docker.sock` and `docker version`; - tries to create a draft release with its token. - A second job tests the same with no `permissions` key, for comparison. ## Outcome / fix - **Both blocked:** close this issue. - **Daemon reachable:** run the release job on a runner or label with `container.docker_host: "-"`, not privileged, with a pinned image. - **Write allowed under `contents: read`:** pin `setup-php` to a SHA and commit `composer.lock` for tag builds, or move the gates to a separate workflow with no token.
Author
Owner

Check run 2026-10-16: both premises fail

Setup. Throwaway private repo eric/runner-check on Forgejo 17.0.0 (gitea-1.22.0 API), same runner, runs-on: ubuntu-latest (image docker.gitea.com/runner-images:ubuntu-latest). One tag push (0.0.1) triggered two workflows:

  • restricted.yml, with top-level permissions: 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.

Probe contents: read no permissions
POST /releases (draft) with secrets.GITHUB_TOKEN 201 (then DELETE 204) 201 (then 204)
DOCKER_HOST tcp://host.docker.internal:2375 same
Docker API GET /version over that address 200 (unauthenticated, plaintext) 200
/var/run/docker.sock absent absent
CapEff 00000000a80425fb (default docker set, not privileged) same
Runner-provided vars ACTIONS_RUNTIME_TOKEN, ACTIONS_ID_TOKEN_REQUEST_TOKEN present same

What this means

  1. permissions: contents: read is not enforced on this instance. Every job's automatic token can create releases. In release.yml that includes static and the three unit jobs, which run setup-php@v2 (floating tag) and freshly resolved composer dev dependencies. Any of that code can upload or replace multi-domain-redux.<v>.zip. The "write only in the third-party-free release job" confinement from #18 does not hold.
  2. Every job controls the Docker daemon. The daemon is reachable unauthenticated over TCP. It is very likely the same daemon that runs the release job's container, so gate-job code can docker exec into, or tamper with, the release job and its image. This applies to ci.yml too, 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 runner config.yml so jobs don't get DOCKER_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 permissions on this Forgejo version. Make the gate jobs token-free instead:

    • pin setup-php to a commit SHA and commit composer.lock, or
    • run the gates in a separate workflow and have the publishing job alone carry the token. Note that the automatic token is per run, so the split only helps if the gates no longer run third-party code in the same run.

    Re-check whether a newer Forgejo enforces permissions.

  • Defence in depth: publish a detached signature or SHA-256 that UpdateChecker verifies 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.

## Check run 2026-10-16: both premises fail **Setup.** Throwaway private repo `eric/runner-check` on Forgejo 17.0.0 (gitea-1.22.0 API), same runner, `runs-on: ubuntu-latest` (image `docker.gitea.com/runner-images:ubuntu-latest`). One tag push (`0.0.1`) triggered two workflows: - `restricted.yml`, with top-level `permissions: 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. | Probe | `contents: read` | no `permissions` | |---|---|---| | `POST /releases` (draft) with `secrets.GITHUB_TOKEN` | **201** (then `DELETE` 204) | 201 (then 204) | | `DOCKER_HOST` | `tcp://host.docker.internal:2375` | same | | Docker API `GET /version` over that address | **200** (unauthenticated, plaintext) | 200 | | `/var/run/docker.sock` | absent | absent | | `CapEff` | `00000000a80425fb` (default docker set, not privileged) | same | | Runner-provided vars | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` present | same | ## What this means 1. **`permissions: contents: read` is not enforced on this instance.** Every job's automatic token can create releases. In `release.yml` that includes `static` and the three `unit` jobs, which run `setup-php@v2` (floating tag) and freshly resolved composer dev dependencies. Any of that code can upload or replace `multi-domain-redux.<v>.zip`. The "write only in the third-party-free release job" confinement from #18 does not hold. 2. **Every job controls the Docker daemon.** The daemon is reachable unauthenticated over TCP. It is very likely the same daemon that runs the release job's container, so gate-job code can `docker exec` into, or tamper with, the release job and its image. This applies to `ci.yml` too, 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 runner `config.yml` so jobs don't get `DOCKER_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 `permissions` on this Forgejo version. Make the gate jobs token-free instead: - pin `setup-php` to a commit SHA and commit `composer.lock`, or - run the gates in a separate workflow and have the publishing job alone carry the token. Note that the automatic token is per run, so the split only helps if the gates no longer run third-party code in the same run. Re-check whether a newer Forgejo enforces `permissions`. - **Defence in depth:** publish a detached signature or SHA-256 that `UpdateChecker` verifies 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.
Author
Owner

Results (probe on eric/runner-check, same runner)

Before (runs 328/329, 0.0.1):

  • permissions: contents: read ignored: draft release created (201) in both the restricted and default jobs.
  • Daemon reachable: DOCKER_HOST=tcp://host.docker.internal:2375 injected via runner container.options, /version → 200, no TLS.

Fixes

  • Runner/stack (the-pond f282a09): container.options: "", docker_host: "-", unprivileged jobs, valid_volumes: []; DinD on its default entrypoint with DOCKER_TLS_CERTDIR=/certs (2376, --tlsverify), client certs only in the runner container.
  • Repo (91a21b7): every uses: pinned to a commit SHA, composer.lock committed (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):

  • No DOCKER_HOST, no /var/run/docker.sock, no /certs in the job.
  • 2375 closed on the gateway (172.20.0.1), host.docker.internal, docker, dind, 172.17.0.1.
  • 2376 on the gateway is TLS-only; no client cert → handshake fails, docker version refused.

Daemon not reachable from jobs. Token remains publish-capable (Forgejo limitation); the SHA pins + lockfile are the control for that.

## Results (probe on eric/runner-check, same runner) **Before** (runs 328/329, `0.0.1`): - `permissions: contents: read` **ignored**: draft release created (201) in both the restricted and default jobs. - Daemon **reachable**: `DOCKER_HOST=tcp://host.docker.internal:2375` injected via runner `container.options`, `/version` → 200, no TLS. **Fixes** - Runner/stack (the-pond `f282a09`): `container.options: ""`, `docker_host: "-"`, unprivileged jobs, `valid_volumes: []`; DinD on its default entrypoint with `DOCKER_TLS_CERTDIR=/certs` (2376, `--tlsverify`), client certs only in the runner container. - Repo (`91a21b7`): every `uses:` pinned to a commit SHA, `composer.lock` committed (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`): - No `DOCKER_HOST`, no `/var/run/docker.sock`, no `/certs` in the job. - 2375 closed on the gateway (172.20.0.1), `host.docker.internal`, `docker`, `dind`, 172.17.0.1. - 2376 on the gateway is TLS-only; no client cert → handshake fails, `docker version` refused. Daemon not reachable from jobs. Token remains publish-capable (Forgejo limitation); the SHA pins + lockfile are the control for that.
eric closed this issue 2026-10-16 15:21:06 -07:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eric/multi-domain-redux#24
No description provided.