workflow_dispatch still loads attacker YAML from an arbitrary ref — four workflows, no ref restriction #853

Closed
opened 2026-08-27 20:17:52 +02:00 by timothy · 2 comments
Owner

Split out of #744, raised by its cold review.

The residual

Start with the one this issue's parent could not fix: a push filter is itself ref-supplied.
ci-image.yml now carries branches: [main], but Gitea loads that line from the pushed ref along
with the rest of the file — so a branch whose ci-image.yml deletes the filter fires exactly as
before. #744 therefore closed the drive-by route (publication as a side effect of an ordinary push
of a legitimate docker/ci change) and nothing more. Every push-triggered workflow in the repo has
this property; it is not a defect in #744's patch, it is the shape of ref-resolved CI.

#744 closed the push route into ci-image.yml (branches: [main], ci.toolchain-image-publish-is-a-dispatch). It deliberately did not close the dispatch route, and the review is right that the actor class, the credentials, the runner and the consequence are unchanged across it — only the number of deliberate steps went up by one.

Sort these by the REF CLASS a trigger admits, never by which keyword it carries. docker-build.yml has branches: [main] and passes a keyword test, while remaining reachable from an arbitrary ref two other ways. Gitea loads a workflow_dispatch definition from the selected ref, so a write-capable actor can also push a branch whose workflow body is arbitrary and then dispatch it:

Workflow Dispatch What the job reaches
ci-image.yml unrestricted ref REGISTRY_PASSWORD, docker daemon — can overwrite ersatztv:prod and a pinned ersatztv-ci:<sha>
docker-build.yml unrestricted ref same registry credential
docker-build.yml no dispatch neededtags: ['v*'] push git push origin <any-commit>:refs/tags/v99.9.9 runs that commit's workflow and can publish ersatztv:prod. Tag pushes are explicitly outside release.main-direct-push-disabled
docker-build.yml no dispatch neededpull_request: head-resolved, same registry credential
dependency-scan.yml unrestricted ref scan credentials
renovate.yml unrestricted ref RENOVATE_TOKEN (can push branches)

review-verdict.yml is NOT in this class: pull_request_target + branches: [main] resolves the base definition, not the head's.

Why #744 did not fix it

#744's scope was the push route, whose defining property is that it required no deliberate act at all — pushing a feature branch was enough. Dispatch requires an actor who already has repository write access, which is a strictly smaller set and a different threat model. Widening #744 to the dispatch class would have meant redesigning how the toolchain image is published in a PR that already had to land alone.

The claim in ci.toolchain-image-publish-is-a-dispatch and in docs/ci-cd.md is scoped accordingly ("protection against an accidental or drive-by branch push, not against a malicious or compromised writer"), so today's state is documented-and-tracked rather than silently wrong. This issue is the tracking.

Options

  1. Accept and record it. Argue the boundary is repository write access, and that anyone inside it can already do worse (push to a branch that a maintainer merges, forge a review-verdict/h10 status — see ci.actions-credential-scoping, which establishes that ANY repo-write credential can write statuses). Cheapest, and possibly correct: this may be a property of the trust boundary rather than a defect in these four files.
  2. Move the privileged half behind a trusted definition — e.g. publish only from a main-resolved workflow, with dispatch limited to requesting a build rather than supplying its body.
  3. Restrict dispatch at the platform level if Gitea 1.27.1 offers a ref restriction or an environment/secret gate. Unprobed — /api/v1/settings/actions 404s at 1.27.1 (ci.actions-credential-scoping), so this needs a probe before it can be costed.

Option 1 is the likely answer, and the deliverable is then a decision record, not code. Do NOT close this by asserting the boundary without checking option 3 first — an unprobed "nothing can be done" is the shape this repo has been wrong about before.

Done-when

  • Whether Gitea 1.27.1 can restrict workflow_dispatch by ref (or gate a secret behind a protected environment) is PROBED, not assumed
  • A decision is recorded either way — accepted-with-reason, or fixed
  • If accepted: ci.actions-credential-scoping and ci.toolchain-image-publish-is-a-dispatch both say so, so the next reader does not re-derive it
  • Adversarial review passed
Split out of #744, raised by its cold review. ## The residual **Start with the one this issue's parent could not fix: a `push` filter is itself ref-supplied.** `ci-image.yml` now carries `branches: [main]`, but Gitea loads that line from the pushed ref along with the rest of the file — so a branch whose `ci-image.yml` deletes the filter fires exactly as before. #744 therefore closed the *drive-by* route (publication as a side effect of an ordinary push of a legitimate `docker/ci` change) and nothing more. Every `push`-triggered workflow in the repo has this property; it is not a defect in #744's patch, it is the shape of ref-resolved CI. #744 closed the **push** route into `ci-image.yml` (`branches: [main]`, `ci.toolchain-image-publish-is-a-dispatch`). It deliberately did **not** close the dispatch route, and the review is right that the actor class, the credentials, the runner and the consequence are unchanged across it — only the number of deliberate steps went up by one. **Sort these by the REF CLASS a trigger admits, never by which keyword it carries.** `docker-build.yml` has `branches: [main]` and passes a keyword test, while remaining reachable from an arbitrary ref two other ways. Gitea loads a `workflow_dispatch` definition from the **selected ref**, so a write-capable actor can also push a branch whose workflow body is arbitrary and then dispatch it: | Workflow | Dispatch | What the job reaches | |---|---|---| | `ci-image.yml` | unrestricted ref | `REGISTRY_PASSWORD`, docker daemon — can overwrite `ersatztv:prod` and a pinned `ersatztv-ci:<sha>` | | `docker-build.yml` | unrestricted ref | same registry credential | | `docker-build.yml` | **no dispatch needed** — `tags: ['v*']` push | `git push origin <any-commit>:refs/tags/v99.9.9` runs that commit's workflow and can publish `ersatztv:prod`. Tag pushes are explicitly outside `release.main-direct-push-disabled` | | `docker-build.yml` | **no dispatch needed** — `pull_request:` | head-resolved, same registry credential | | `dependency-scan.yml` | unrestricted ref | scan credentials | | `renovate.yml` | unrestricted ref | `RENOVATE_TOKEN` (can push branches) | `review-verdict.yml` is NOT in this class: `pull_request_target` + `branches: [main]` resolves the base definition, not the head's. ## Why #744 did not fix it #744's scope was the push route, whose defining property is that it required **no deliberate act at all** — pushing a feature branch was enough. Dispatch requires an actor who already has repository write access, which is a strictly smaller set and a different threat model. Widening #744 to the dispatch class would have meant redesigning how the toolchain image is published in a PR that already had to land alone. The claim in `ci.toolchain-image-publish-is-a-dispatch` and in `docs/ci-cd.md` is scoped accordingly ("protection against an accidental or drive-by branch push, not against a malicious or compromised writer"), so today's state is documented-and-tracked rather than silently wrong. This issue is the tracking. ## Options 1. **Accept and record it.** Argue the boundary is repository write access, and that anyone inside it can already do worse (push to a branch that a maintainer merges, forge a `review-verdict/h10` status — see `ci.actions-credential-scoping`, which establishes that ANY repo-write credential can write statuses). Cheapest, and possibly correct: this may be a property of the trust boundary rather than a defect in these four files. 2. **Move the privileged half behind a trusted definition** — e.g. publish only from a `main`-resolved workflow, with dispatch limited to requesting a build rather than supplying its body. 3. **Restrict dispatch at the platform level** if Gitea 1.27.1 offers a ref restriction or an environment/secret gate. Unprobed — `/api/v1/settings/actions` 404s at 1.27.1 (`ci.actions-credential-scoping`), so this needs a probe before it can be costed. Option 1 is the likely answer, and the deliverable is then a decision record, not code. Do NOT close this by asserting the boundary without checking option 3 first — an unprobed "nothing can be done" is the shape this repo has been wrong about before. ## Done-when - [x] Whether Gitea 1.27.1 can restrict `workflow_dispatch` by ref (or gate a secret behind a protected environment) is PROBED, not assumed - [x] A decision is recorded either way — accepted-with-reason, or fixed - [x] If accepted: `ci.actions-credential-scoping` and `ci.toolchain-image-publish-is-a-dispatch` both say so, so the next reader does not re-derive it - [x] Adversarial review passed
timothy added the ci-cdpriority: mediumsecurity labels 2026-08-27 20:18:00 +02:00
timothy added the in-progress label 2026-08-30 01:32:58 +02:00
Author
Owner

Claiming this (Claude Code session, worktree main-3, branch fix/853-dispatch-ref-restriction).

Pre-claim checks per process.parallel-session-claim: no open PR references #853, git ls-remote --heads origin '*853*' is empty, the issue had no prior comments, and origin/main is freshly fetched at e8f80c42c.

Starting with the Done-when box the issue flags as non-skippable: probing whether Gitea 1.27.1 can restrict workflow_dispatch by ref or gate a secret behind a protected environment — before any accept-and-record conclusion.

Claiming this (Claude Code session, worktree `main-3`, branch `fix/853-dispatch-ref-restriction`). Pre-claim checks per `process.parallel-session-claim`: no open PR references #853, `git ls-remote --heads origin '*853*'` is empty, the issue had no prior comments, and `origin/main` is freshly fetched at `e8f80c42c`. Starting with the Done-when box the issue flags as non-skippable: **probing** whether Gitea 1.27.1 can restrict `workflow_dispatch` by ref or gate a secret behind a protected environment — before any accept-and-record conclusion.
Author
Owner

Closing record

Outcome: Probed and ACCEPTED, with the decision recorded rather than code changed — PR #888 (merged as 1d5024183). New record ci.workflow-dispatch-ref-unrestricted (docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md, stale-after: 2027-02-28), plus the two sibling records this issue named, docs/ci-cd.md, and the ci-image.yml comment block that pointed here as an open class.

Root cause: Not a defect — a platform limit plus a mis-framed question. Gitea 1.27.1 has no mechanism to restrict workflow_dispatch by ref and no protected-environment concept at all, so option 3 does not exist. But the more useful finding is that the question was aimed at the wrong route: dispatch is not the cheapest path to the credential, so restricting it would have closed the more visible of two routes and changed nothing.

Decisions/conventions changed: Added ci.workflow-dispatch-ref-unrestricted. Updated ci.actions-credential-scoping and ci.toolchain-image-publish-is-a-dispatch — both now state the outcome so the next reader does not re-derive it (this issue's Done-when box 3). Note ci.toolchain-image-publish-is-a-dispatch now says only the DISPATCH third of its residual is settled; its v* tag-push and pull_request: rows stay open.

Reusable knowledge:

  • Sort a trigger by the ref class it admits, and then ask which route is CHEAPEST. docker-build.yml's pull_request: is head-resolved and runs attacker-authored YAML; six of its jobs hold REGISTRY_PASSWORD on that route and two are required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one. Restricting the deliberate route while the incidental one stays open is theatre.
  • "Anyone with repo write can already do worse" is the argument to refuse. It is unfalsifiable as stated, so it licenses accepting anything, and it hides the cheaper route.
  • A CLI probe run as the wrong user reports absence. gitea --help as root exits 1 (mustNotRunAsRoot) and prints nothing, so a grep over its output "finds nothing" for entirely the wrong reason. Check the exit code, not the match count. Run as the service user, a gitea actions command does exist.
  • An invariant written to replace a stale list can be stale on day one. "Every container: job on the PR route" names five of the six — toolchain-preflight is deliberately container-free and takes the credential via ETV_REGISTRY_AUTH. The predicate that derives correctly is "every job on the PR route that names secrets.REGISTRY_PASSWORD".
  • The write set here is two accounts, and they are not equivalent. timothy (admin) and renovate (write, non-admin). The merge gate bounds what Renovate can MERGE; nothing bounds what it can RUN — a reader of ci.exemption-provenance could easily infer otherwise.
  • tag_protections is empty and Gitea 1.27.1 does support tag protection.

Verification: Full scripts/tests suite on the rebased base — 1250 passed, 2 skipped against 1252 collected (exact match, so nothing silently failed to collect). decisions_validate.py OK; catalog regenerated byte-identically; ci-image.yml parses with triggers byte-for-byte unchanged (comment-only, so no ci-image-pin churn). Rebased onto origin/main 94a3d1349 after #884 landed, with the job population re-derived on the new base. Four cold review rounds in isolated worktrees: rounds 1-2 NOT-MERGEABLE (12 and 5 findings), rounds 3 and the delta pass MERGEABLE. Deviation: Codex hit its usage limit, so all reviews are same-family rather than cross-family.

Deferred: #885 — the pull_request: credential exposure (the actual residual) and the unused v* tag protection. The tag rule was deliberately NOT applied here: it is protection-class configuration whose failure mode is a broken release cut, so it needs its own change and its own verification that a legitimate tag push still succeeds. Also unswept: the web UI as a probe surface. The record names this gap explicitly rather than claiming an exhaustive sweep, because a Gitea Actions control can exist with no API surface at all — sweep the UI before citing the record as proof no such control CAN exist.

Docs updated: docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md (new), docs/decisions/records/ci/actions-credential-scoping.md, docs/decisions/records/ci/toolchain-image-publish-is-a-dispatch.md, docs/decisions/README.md (regenerated), docs/ci-cd.md, .gitea/workflows/ci-image.yml (comment).

## Closing record **Outcome:** Probed and ACCEPTED, with the decision recorded rather than code changed — PR #888 (merged as 1d5024183). New record `ci.workflow-dispatch-ref-unrestricted` (`docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md`, `stale-after: 2027-02-28`), plus the two sibling records this issue named, `docs/ci-cd.md`, and the `ci-image.yml` comment block that pointed here as an open class. **Root cause:** Not a defect — a platform limit plus a mis-framed question. Gitea 1.27.1 has no mechanism to restrict `workflow_dispatch` by ref and no protected-environment concept at all, so option 3 does not exist. But the more useful finding is that the question was aimed at the wrong route: dispatch is not the cheapest path to the credential, so restricting it would have closed the more *visible* of two routes and changed nothing. **Decisions/conventions changed:** Added `ci.workflow-dispatch-ref-unrestricted`. Updated `ci.actions-credential-scoping` and `ci.toolchain-image-publish-is-a-dispatch` — both now state the outcome so the next reader does not re-derive it (this issue's Done-when box 3). Note `ci.toolchain-image-publish-is-a-dispatch` now says only the DISPATCH third of its residual is settled; its `v*` tag-push and `pull_request:` rows stay open. **Reusable knowledge:** - **Sort a trigger by the ref class it admits, and then ask which route is CHEAPEST.** `docker-build.yml`'s `pull_request:` is head-resolved and runs attacker-authored YAML; six of its jobs hold `REGISTRY_PASSWORD` on that route and two are required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one. Restricting the deliberate route while the incidental one stays open is theatre. - **"Anyone with repo write can already do worse" is the argument to refuse.** It is unfalsifiable as stated, so it licenses accepting anything, and it hides the cheaper route. - **A CLI probe run as the wrong user reports absence.** `gitea --help` as root exits 1 (`mustNotRunAsRoot`) and prints nothing, so a grep over its output "finds nothing" for entirely the wrong reason. Check the exit code, not the match count. Run as the service user, a `gitea actions` command does exist. - **An invariant written to replace a stale list can be stale on day one.** "Every `container:` job on the PR route" names five of the six — `toolchain-preflight` is deliberately container-free and takes the credential via `ETV_REGISTRY_AUTH`. The predicate that derives correctly is "every job on the PR route that names `secrets.REGISTRY_PASSWORD`". - **The write set here is two accounts, and they are not equivalent.** `timothy` (admin) and `renovate` (write, non-admin). The merge gate bounds what Renovate can MERGE; nothing bounds what it can RUN — a reader of `ci.exemption-provenance` could easily infer otherwise. - **`tag_protections` is empty** and Gitea 1.27.1 does support tag protection. **Verification:** Full `scripts/tests` suite on the rebased base — 1250 passed, 2 skipped against 1252 collected (exact match, so nothing silently failed to collect). `decisions_validate.py` OK; catalog regenerated byte-identically; `ci-image.yml` parses with triggers byte-for-byte unchanged (comment-only, so no `ci-image-pin` churn). Rebased onto `origin/main` 94a3d1349 after #884 landed, with the job population re-derived on the new base. Four cold review rounds in isolated worktrees: rounds 1-2 NOT-MERGEABLE (12 and 5 findings), rounds 3 and the delta pass MERGEABLE. **Deviation:** Codex hit its usage limit, so all reviews are same-family rather than cross-family. **Deferred:** #885 — the `pull_request:` credential exposure (the actual residual) and the unused `v*` tag protection. The tag rule was deliberately NOT applied here: it is protection-class configuration whose failure mode is a broken release cut, so it needs its own change and its own verification that a legitimate tag push still succeeds. Also unswept: the **web UI** as a probe surface. The record names this gap explicitly rather than claiming an exhaustive sweep, because a Gitea Actions control can exist with no API surface at all — sweep the UI before citing the record as proof no such control CAN exist. **Docs updated:** `docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md` (new), `docs/decisions/records/ci/actions-credential-scoping.md`, `docs/decisions/records/ci/toolchain-image-publish-is-a-dispatch.md`, `docs/decisions/README.md` (regenerated), `docs/ci-cd.md`, `.gitea/workflows/ci-image.yml` (comment).
timothy removed the in-progress label 2026-08-30 03:18:35 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#853