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
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#853
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #744, raised by its cold review.
The residual
Start with the one this issue's parent could not fix: a
pushfilter is itself ref-supplied.ci-image.ymlnow carriesbranches: [main], but Gitea loads that line from the pushed ref alongwith the rest of the file — so a branch whose
ci-image.ymldeletes the filter fires exactly asbefore. #744 therefore closed the drive-by route (publication as a side effect of an ordinary push
of a legitimate
docker/cichange) and nothing more. Everypush-triggered workflow in the repo hasthis 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.ymlhasbranches: [main]and passes a keyword test, while remaining reachable from an arbitrary ref two other ways. Gitea loads aworkflow_dispatchdefinition from the selected ref, so a write-capable actor can also push a branch whose workflow body is arbitrary and then dispatch it:ci-image.ymlREGISTRY_PASSWORD, docker daemon — can overwriteersatztv:prodand a pinnedersatztv-ci:<sha>docker-build.ymldocker-build.ymltags: ['v*']pushgit push origin <any-commit>:refs/tags/v99.9.9runs that commit's workflow and can publishersatztv:prod. Tag pushes are explicitly outsiderelease.main-direct-push-disableddocker-build.ymlpull_request:dependency-scan.ymlrenovate.ymlRENOVATE_TOKEN(can push branches)review-verdict.ymlis 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-dispatchand indocs/ci-cd.mdis 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
review-verdict/h10status — seeci.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.main-resolved workflow, with dispatch limited to requesting a build rather than supplying its body./api/v1/settings/actions404s 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
workflow_dispatchby ref (or gate a secret behind a protected environment) is PROBED, not assumedci.actions-credential-scopingandci.toolchain-image-publish-is-a-dispatchboth say so, so the next reader does not re-derive itClaiming this (Claude Code session, worktree
main-3, branchfix/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, andorigin/mainis freshly fetched ate8f80c42c.Starting with the Done-when box the issue flags as non-skippable: probing whether Gitea 1.27.1 can restrict
workflow_dispatchby ref or gate a secret behind a protected environment — before any accept-and-record conclusion.Closing record
Outcome: Probed and ACCEPTED, with the decision recorded rather than code changed — PR #888 (merged as
1d5024183). New recordci.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 theci-image.ymlcomment 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_dispatchby 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. Updatedci.actions-credential-scopingandci.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). Noteci.toolchain-image-publish-is-a-dispatchnow says only the DISPATCH third of its residual is settled; itsv*tag-push andpull_request:rows stay open.Reusable knowledge:
docker-build.yml'spull_request:is head-resolved and runs attacker-authored YAML; six of its jobs holdREGISTRY_PASSWORDon 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.gitea --helpas 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, agitea actionscommand does exist.container:job on the PR route" names five of the six —toolchain-preflightis deliberately container-free and takes the credential viaETV_REGISTRY_AUTH. The predicate that derives correctly is "every job on the PR route that namessecrets.REGISTRY_PASSWORD".timothy(admin) andrenovate(write, non-admin). The merge gate bounds what Renovate can MERGE; nothing bounds what it can RUN — a reader ofci.exemption-provenancecould easily infer otherwise.tag_protectionsis empty and Gitea 1.27.1 does support tag protection.Verification: Full
scripts/testssuite on the rebased base — 1250 passed, 2 skipped against 1252 collected (exact match, so nothing silently failed to collect).decisions_validate.pyOK; catalog regenerated byte-identically;ci-image.ymlparses with triggers byte-for-byte unchanged (comment-only, so noci-image-pinchurn). Rebased ontoorigin/main94a3d1349after #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 unusedv*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).