ETV_STATUS_AUTH can write commit statuses, so any head-resolved workflow can still forge review-verdict/h10
#697
Closed
opened 2026-07-28 21:37:18 +02:00 by timothy
·
5 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
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.16.0
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#697
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.
Found while fixing #672, by probing rather than reasoning. #672 narrows the hole; this is the rest of it.
The finding
#672 switches
review-verdict.ymltopull_request_targetso a PR can no longer rewrite that workflow's definition. But the vulnerability class is "a head-resolved workflow holding credentials that can POST a commit status", andreview-verdict.ymlis not the only such workflow.docker-build.ymltriggers onpull_request— therefore head-resolved — and carries:for
scripts/ci-detect-already-validated.sh(#420), which only ever reads statuses. Those are Gitea basic-auth credentials, and basic auth is not scoped — the same account can write.Confirmed empirically, not inferred
Scratch PR #696 replaced
docker-build.ymlwith a minimal job that POSTed a probe-named context (review-verdict/h10-PROBE672D, never the realh10) using those credentials. It succeeded — the status appeared on the head sha. A real attacker changes one string and postsreview-verdict/h10=success.Why the #672 fix cannot be extended here
docker-build.ymlmust stay onpull_request: it builds and tests the PR's code, so it has to resolve from the head.pull_request_targetwould be actively dangerous there — it is precisely the "privileged token + untrusted head code" combination. So this needs a different fix.Options
ETV_STATUS_AUTHa read-only identity. The script only reads. A dedicated Gitea account/token without status-write closes it without touching any workflow logic. Note a token also needspackages:writekept separate from status-write, so this likely means splitting the registry credential from the status-read credential.Option 1 looks right and is mostly an infra/secrets change (server-management territory for the credential itself).
Severity
Same as #672: requires push access, so the threat model is a compromised contributor, not an anonymous attacker. Not remotely exploitable. Worth closing because the whole point of H10 is that merge-consent is derived, not assertable.
Done-when
.gitea/workflows/*.ymlfor credentials that can write statuses, not justdocker-build.yml— enumerate the full set rather than fixing the one instance foundci.gate-trigger-base-resolvedupdated to record the resolved state (it currently names this as an open residual)Independent review of the #672 PR surfaced a second instance, so the "audit every workflow" box is not hypothetical — the enumeration already has two entries and I only found one by probing.
ci-image.yml—on: pushwith nobranches:filter, path-scoped to.gitea/workflows/ci-image.ymlitself, holding the sameREGISTRY_USER/REGISTRY_PASSWORD. No PR is needed at all: pushing a feature branch that edits that file is enough to run attacker-supplied YAML with credentials that can write commit statuses.Also worth noting for whoever takes this: a same-repo PR gets repo secrets, and Gitea injects
secrets.GITEA_TOKENper run — so adding a new.gitea/workflows/*.ymlwithon: pull_requestis a route too, not just editing an existing one. That makes "audit the current set" necessary but not sufficient; the durable fix has to be about what credentials PR-triggered runs can obtain, not about which files exist today.A trap for the obvious fix. Branch protection's required contexts are currently:
Two of those carry the literal
(pull_request)suffix. Switchingdocker-build.ymltopull_request_target(which you should not do anyway — it builds PR code) would rename both contexts and deadlock every merge until branch protection is edited in the same operation.Partial mitigation already landed in the #672 PR: a test asserting that no workflow other than
review-verdict.ymlreferencesreview-verdict/h10in executable lines. That catches a second workflow openly writing the context; it does not catch one doing it through a variable or a script it calls, and its docstring says so. It is a drift guard, not a boundary.Cross-family review of the #672 PR widens this considerably. The scope in the issue title is too narrow — this is not really about
ETV_STATUS_AUTH.Gitea injects
${{ secrets.GITEA_TOKEN }}into every job automatically, whether or not the workflow mentions it, and its default permission set is read/write for backwards compatibility (token-permissions). Our ownreview-verdict.ymlproves that token can POST commit statuses here. So the credential inventory is not "the two workflows that reference registry creds" — it is every workflow, plus a handful with extra credentials on top:pr-checks.ymlpull_requestdocker-build.ymlpull_request,workflow_dispatch,v*tagsETV_STATUS_AUTH, registryci-image.ymlpush(path-scoped to itself)dependency-scan.ymlworkflow_dispatchrenovate.ymlworkflow_dispatchworkflow_dispatchmatters because Gitea 1.24+ loads the workflow definition from the selected branch, so it is another self-supplied-definition path, not just a manual trigger. Anddocker-build.ymlis reachable via an attacker-createdv*tag if tags aren't protected.There is also a route with no workflow at all: a collaborator's own write-scoped API token can POST the context directly, because branch protection binds the required context, not its issuer.
Consequence for the fix. Option 1 in the issue body (read-only identity for
ETV_STATUS_AUTH) is necessary but not sufficient — it leaves the built-in token untouched. The durable shape is:h10posting to a credential that ref-resolved workflows cannot obtain, and/or enforce the expected issuer server-side.Since branch protection cannot express "posted by X", 3 probably means a server-side check or an independent required approval rather than anything expressible in a workflow file.
Also honest: no in-repository test can establish status-authority isolation — the guard added in the #672 PR only catches a workflow that names the context in plain text. Related exemption-path defects split out into #698.
Claiming this (Claude Code session, 2026-08-05). Starting from the two review comments above: treating the scope as the widened one (every ref-resolved workflow + the injected
GITEA_TOKEN), not theETV_STATUS_AUTH-only framing in the title.Audit deliverable (Done-when box 3): the enumeration, and why the unit is wrong
The box asks to audit every workflow for status-write-capable credentials. Doing that surfaced that the workflow set is the wrong unit of enumeration: Actions secrets are a per-repo store, and a same-repo PR can add a workflow that references any secret in it. Auditing today's six files is therefore necessary but structurally insufficient — which the second review comment above had already predicted.
By workflow (verified 2026-08-05, Gitea 1.25.4)
permissions:pr-checks.ymlpull_requestdocker-build.ymlpull_request,push(main +v*),workflow_dispatchETV_STATUS_AUTH, registryci-image.ymlpush(no branch filter, path-scoped to itself),workflow_dispatch,scheduledependency-scan.ymlworkflow_dispatch,schedulerenovate.ymlworkflow_dispatch,scheduleRENOVATE_TOKEN,GH_COM_TOKEN, registryreview-verdict.ymlpull_request_target(branches: [main])GITEA_TOKENNo workflow declares
permissions:— and that is correct, because the key is a no-op on 1.25.4. It landed in Gitea 1.26.0 (PR #36173); there is no parsing or enforcement of it inservices/actions/at tagv1.25.4. Adding it today would be false comfort: inert, while reading in review as a constraint.By secret (the unit that actually matters)
REGISTRY_USER/REGISTRY_PASSWORDcreator: timothy, i.e. durableRENOVATE_TOKENwrite:repositorybot PATGH_COM_TOKENSERVERMGMT_DEPLOY_KEYGITEA_TOKENcreator: null→ re-derived, must win a raceTwo findings not in the issue
RENOVATE_TOKENis a surviving DURABLE route. It iswrite:repositoryon a real bot account, so a status posted with it carries a non-null.creator.loginand is inherited as a human verdict byreview-verdict.yml's provenance check. It cannot be scoped down — Renovate needs repo write to push branches and open PRs. Closing this needs the provenance check tightened from "non-null creator" to an allow-list of approved reviewers; deliberately not done in this PR, because an edit to the gate goes live only on merge, repo-wide and untested.SERVERMGMT_DEPLOY_KEYis an orphan. Thebump-prod-composejob that used it was removed in1b5efd7b9; the secret was never deleted. It is an SSH deploy key to another repo, obtainable by any PR-added workflow, with no remaining benefit. Enumerating workflows would never have found it — enumerating the store did.Severity, unchanged
All of this requires push access, so the threat model remains a compromised contributor, not an anonymous attacker. What the audit did change is the blast radius:
REGISTRY_PASSWORDwas the admin account's basic auth, unscoped, handed to head-resolved PR code — so the exposure was the whole Gitea instance (every repo on it), not merely this repo's merge gate.Closing record
Outcome: Fixed and merged via PR #745.
REGISTRY_PASSWORDis no longer the admin account's basic auth but a PAT scopedwrite:package+read:repository. The issue's own framing was too narrow twice over, and correcting it produced three follow-ups that matter more than the fix.Root cause:
docker-build.ymltriggers onpull_request— head-resolved — while carryingETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}. Those were the admin account's credentials, and basic auth carries no scope. So a PR's own code received instance-admin access: not merely the ability to forgereview-verdict/h10, but administrative control of every repo on the instance. Gitea has nostatustoken scope (POST /statusesis gated byreqRepoWriter(unit.TypeCode)), so status-write cannot be separated from repo-write — butwrite:packageis a separate scope, andci-detect-already-validated.shonly ever GETs. Hence a like-for-like credential that cannot post a status.Decisions/conventions changed: Added
ci.actions-credential-scoping. Correctedci.gate-trigger-base-resolved,release.verdict-status-checkandci.exemption-provenance, all of which named #697 as open; two also overstated what the fix closes.Reusable knowledge:
RENOVATE_TOKENand the orphanedSERVERMGMT_DEPLOY_KEY; neither was reachable by the audit the issue asked for..creator.loginand is INHERITED as a human verdict; one POSTed by an Actions job carriescreator: nulland is re-derived. But re-derivation is not protection: it fires only on the trigger'stypes, and posting a status is not one of them, so a POST timed after the last PR event simply stands.::warning::, not a cap (#520; #688 v5 split the test somarks_tailis pinned against synthetic data). The blocking constraint is a 2–25% minority band, currently 10.8% with ~38 records of slack. Do not compress prose to satisfy it..gitea/workflows/ci-image.ymlre-pointsci-image-pin'sexpectedat the editing commit, staling all fivecontainer:pins and failing a BLOCKING job — for a change altering zero bytes of the image. A working-tree revert is not enough:git log -1 -- <path>reads history, so the commit must be amended.Verification: Registry push of a probe tag SUCCEEDED (deleted, confirmed 404);
GET /commits/{sha}/status200;POST /statuses/{sha}403required=[write:repository], token scope=write:package,read:repository— the #696-shaped probe now refused, which is Done-when box 2. An independent review separately confirmed no CI consumption breaks (write implies read per category). Locally: 432 script tests, decisions validator and catalog check all green. Three cold adversarial review rounds: BLOCKED (1B/4H/3M/2L) → BLOCKED (1B/2H/4M/2L) → BLOCKED (1M) → MERGEABLE.Deferred:
priority: high) — direct pushes tomainare server-side permitted (enable_push: true, empty whitelist), so the gate is bypassable with no forgery at all. This is larger than #697 and corrects its premise: a push whitelist closes more of this class than the Gitea 1.26 upgrade does.RENOVATE_TOKENroute.ci-image.yml's unfiltered push trigger; attempted here and reverted (see above), with the false "branch publishing is not load-bearing" justification recorded.GITEA_TOKEN.SERVERMGMT_DEPLOY_KEYleft in place by explicit decision, with the fact that its key on server-management isread_only: false— write access to the repo holding prod's GitOps stack definitions — recorded so it is accepted rather than forgotten.Docs updated:
docs/ci-cd.md;docs/decisions/records/ci/actions-credential-scoping.md(new);docs/decisions/records/ci/gate-trigger-base-resolved.md;docs/decisions/records/ci/exemption-provenance.md;docs/decisions/records/release/verdict-status-check.md;docs/decisions/README.md(regenerated). Also two comments in.gitea/workflows/review-verdict.ymland two docstrings inscripts/tests/test_pr_changed_files.py.Honest note on severity: unchanged throughout — push access is required, so the threat model is a compromised contributor or a subverted automated session, never an anonymous attacker.
review-verdict/h10is a process guard against mistakes and drift, not a security boundary against push access. This PR closes the admin escalation; it does not make the gate unforgeable, and the record says so in four places rather than claiming otherwise.