Files
ersatztv/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md
T
1d50241833
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 21s
docs(853): workflow_dispatch can't be ref-restricted at 1.27.1 — and restricting it would close nothing (#888)
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable.

Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one.

Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right.

Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885.

The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all.

fixes #853

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 01:18:03 +00:00

12 KiB

key, title, status, since, supersedes, superseded-by, stale-after, rule, signals, mechanics, sources
key title status since supersedes superseded-by stale-after rule signals mechanics sources
ci.workflow-dispatch-ref-unrestricted 2026-08-30 — `workflow_dispatch` cannot be ref-restricted at Gitea 1.27.1, and restricting it would close nothing while `docker-build.yml` keeps `pull_request:` (#853) active 2026-08-30 none none 2027-02-28 Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch. workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections empty, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845 `docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push" Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls
  • The two capability questions the issue named were probed to a negative, and the negatives are the durable part of this record. There is no ref restriction for workflow_dispatch at 1.27.1: the endpoint takes ref as a required free-form string and no repo, org or admin endpoint scopes which refs may be selected. There is no protected-environment concept to gate a secret behind — not a missing endpoint but a missing feature, evidenced by zero of 308 documented paths mentioning "environment" and by secrets existing only at org, repo and user scope. THREE surfaces were swept, because "the obvious call 404s" is not evidence of absence: the REST API, the config file the running server names in its own --config, and the gitea CLI. The web UI was not swept, and that is a real limit on this evidence rather than a formality — a Gitea Actions control can exist with NO API surface at all, which ci.actions-credential-scoping records for the owner-level default: it 404s on the API and was nonetheless set, verified and reverted. That record does NOT name the surface used — a non-API one is an INFERENCE by elimination here, since app.ini sets only two [actions] keys and the CLI exposes only generate-runner-token. It is left open because the decision does not rest on it — what carries the conclusion is the secret-scope model (org/repo/user only, no per-ref gate) plus the fact that restricting dispatch would close nothing regardless. Sweep the UI before citing this record as proof that no such control CAN exist. Check the CLI's exit code, not its match count — run as root the binary refuses to start and exits 1, so grepping its empty help output "finds nothing" for entirely the wrong reason; run as the service user it does list an actions command, whose sole subcommand is generate-runner-token. Re-probe on the VERSION rather than the date: stale-after is a calendar field, but what invalidates all of this is an upgrade past 1.27.1, which can land months earlier.
  • Do not close this class with "anyone with repository write can already do worse." That argument was the issue's own leading option and it is the one to reject, for two reasons. It is unfalsifiable as stated, so it licenses accepting anything; and it skips the question that actually decides the matter, which is whether dispatch is the cheapest route to the credential. It is not.
  • pull_request: is the cheaper route, and it is the finding that settles the decision. docker-build.yml triggers on pull_request:, which Gitea resolves from the PR head, so the run executes attacker-authored YAML — and that, not the specific jobs, is the whole argument: such YAML can name any secret in the repo store, not only the ones the committed workflows reference. ci.gate-trigger-base-resolved states it directly — "any PR-added workflow can reference RENOVATE_TOKEN, a write:repository bot PAT in the same store". That step is INFERRED from the repo-scoped secret model plus that record, not measured here: measuring it prints a live credential into a run log. That is the step that makes this cover all four dispatches, renovate.yml included, even though renovate.yml has no pull_request: trigger of its own; without it the argument would reach only the registry pair. Concretely on the registry credential today, SIX jobs hold REGISTRY_PASSWORD and run on the PR route — toolchain-preflight, test, migrations, functional-e2e, api-docs, format — of which test and migrations are both branch-protection required contexts (.gitea/required-status-contexts.json). Carry the invariant, not the list: every job on the PR route that names secrets.REGISTRY_PASSWORD, because a remediation scoped to today's six names silently misses the seventh. Not "every container: job" — toolchain-preflight is deliberately container-free (runs-on: small, credential via ETV_REGISTRY_AUTH), so that predicate names five of six and is stale before it ships. So "push a branch, open a PR" reaches the credential with no act outside the ordinary contribution flow, where a dispatch costs one — that is this repo's sense of "deliberate" (ci.toolchain-image-publish-is-a-dispatch), not a raw step count, which would read 2 vs 2. Any control that restricted dispatch and left this open would close the more visible route and report progress that did not happen. Per-if: ref gates on the publish steps do not change this: they live in the same head-supplied file and an attacker edits them out.
  • The write set is two accounts, and the asymmetry between them is worth stating once. They are timothy (owner, site admin) and renovate (collaborator, permission: write, is_admin: false). Against the admin, no control administered by that same admin is a boundary — that is a property of the trust model, not a defect. Against the bot it is different, and the difference is easy to misread: considerable machinery bounds what Renovate can get MERGED (review-verdict/h10 as a required context, and the exemption that auto-passes a Renovate PR only when every changed path is a dependency manifest AND none is under any of the six protected directories .claude/, .codex/, .gitea/, .husky/, scripts/, docker/ci/ — #698, #742, #845; ci.exemption-provenance has the authoritative form). None of that machinery bounds what Renovate can RUN. A reader of ci.exemption-provenance could reasonably infer the bot is tightly constrained; on the run path it is not. Measured rather than inferred: on a matched probe against the same endpoint and payload, an authenticated non-writer short-circuits at authorization with "not found" while renovate on this repo reaches ref resolution with "ref ... doesn't exist" — the same response a known writer gets — so renovate passes the dispatch authorization gate.
  • One cheap control exists and was deliberately NOT taken here: tag_protections is empty. Gitea 1.27.1 does support tag protection (name_pattern plus a username/team whitelist), and a v* rule whitelisting the release operator would close docker-build.yml's tag-push row against the bot at no operational cost, since only the operator cuts releases. It is not applied in this change because it is live branch-protection-class configuration whose failure mode is a broken release cut, which deserves its own change and its own verification that a legitimate tag push still succeeds — not a rider on a decision record. It carries no weight against the admin actor either way. Tracked with the pull_request: residual in #885.