CVE-2026-31852: The Pull Request That Checked Out Your Secrets
A GitHub Actions workflow that trusts a fork's code *and* hands it the keys: `pull_request_target` plus a checkout of the attacker's commit is the CI/CD equivalent of running an email attachment as root. Jellyfin's iOS repo shipped exactly that combination, and it scored a clean 10.0.
A GitHub Actions workflow that trusts a fork's code and hands it the keys: pull_request_target plus a checkout of the attacker's commit is the CI/CD equivalent of running an email attachment as root. Jellyfin's iOS repo shipped exactly that combination, and it scored a clean 10.0.
The advisory in plain English
CVE-2026-31852 is not a bug in the Jellyfin iOS app. There is no memory corruption, no injected SQL, no parser to fuzz. The vulnerable artifact is .github/workflows/code-quality.yml — a continuous-integration workflow — and the flaw is a textbook "pwn request": a privileged workflow trigger combined with checkout and execution of untrusted, attacker-supplied code.
The impact is what pushes it to critical. Because the workflow ran with the repository's default (near-total write) token permissions and exposed high-value secrets, a successful trigger meant full takeover of jellyfin/jellyfin-ios: exfiltration of the bot token and SonarCloud token, the ability to push code, poison ghcr.io packages, and — via cross-repository token reuse — pivot into the wider Jellyfin organization and the Apple App Store release pipeline. No end-user action is required; there's no app version to patch. The fix lives entirely in the CI configuration.
The flawed workflow
Two lines, sitting a dozen rows apart, are the whole story. From .github/workflows/code-quality.yml @ b30fda8, the trigger (L10):
on:
push:
branches: [ master ]
pull_request_target:
branches: [ master ]
And the first thing the eslint job does, .github/workflows/code-quality.yml @ b30fda8, L20–L22:
- name: Check out Git repository
uses: actions/checkout@93cb6efe... # v5.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
To understand why this is fatal, you need the semantics of the two pull-request triggers.
pull_request runs in the context of the fork's head, with a read-only token and no access to secrets for PRs originating from forks. It is the safe default precisely because it assumes the incoming code is hostile.
pull_request_target, by contrast, runs in the context of the base repository. It checks out the base branch by default, receives the repository's full secrets context, and gets a GITHUB_TOKEN with the repo's configured permissions — for PRs from any fork, including a brand-new throwaway account. It exists so that trusted automation (labelers, welcome bots) can act on PRs without a maintainer having to approve every run. The iron rule attached to it is: never check out or execute the PR's code.
Line 22 breaks that rule. ref: ${{ github.event.pull_request.head.sha }} explicitly replaces the trusted base-branch checkout with the exact commit the pull-request author pushed. From that point the runner's working tree is 100% attacker-controlled, and it is still holding the privileged token and every secret.
Why the "check" was insufficient — there wasn't one
The workflow does have a guard, but it protects the wrong thing. On the SonarCloud step, .github/workflows/code-quality.yml @ b30fda8, L76 gates execution with if: ${{ github.repository == 'jellyfin/jellyfin-ios' }}. That condition only stops the scan from running on downstream forks; it does nothing to establish that the code being scanned is trustworthy. The privileged context and the untrusted checkout coexist with no barrier between them.
Now trace the source to the sink. The attacker-controlled source is the fork PR head at L22. The tree that lands on disk includes package.json, whose lint script an attacker rewrites in their fork. Two steps later the job runs, .github/workflows/code-quality.yml @ b30fda8, L32 & L35:
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run eslint
run: npm run lint
These are the sinks. npm ci executes lifecycle hooks (preinstall/postinstall) declared in the checked-out — i.e. attacker-authored — package.json and its dependency tree. npm run lint then executes whatever command string the attacker placed in the scripts.lint field; upstream that string is eslint "." (package.json @ b30fda8, L8), but in the fork it can be any shell command. Either sink runs arbitrary code on the runner, inside the pull_request_target context.
What is sitting in that context to steal? The job's own environment, .github/workflows/code-quality.yml @ b30fda8, L83–L84:
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
JF_BOT_TOKEN is a bot credential with organization reach; SONAR_TOKEN is the code-analysis secret. And crucially, the workflow declares no permissions: block at all — I confirmed its absence in the pre-fix file. When a workflow omits permissions, the GITHUB_TOKEN inherits the repository/organization default, which for this org was, per the advisory, "nearly all write permissions." So the source→sink path is: unauthenticated fork PR → checkout of PR head → npm script execution → runner holding a write-scoped GITHUB_TOKEN plus a cross-repo bot token, with no sanitizer, no maintainer-approval gate, and no permissions floor in between.
That is the entire kill chain, and none of it requires the attacker to be a collaborator. Opening a pull request is enough to trigger it. The reachability here isn't a matter of taint heuristics — it's the documented behavior of the trigger, and the diff confirms every link.
What the fix changed
Commit 109217e ("Split SonarCloud into separate workflow") does two disciplined things.
First, in code-quality.yml it swaps the trigger back to the safe one and deletes the dangerous checkout ref:
- pull_request_target:
+ pull_request:
...
- with:
- ref: ${{ github.event.pull_request.head.sha }}
Under plain pull_request, the fork's code still runs npm ci and npm run lint — but now in an unprivileged context: read-only token, no secrets. Executing untrusted code is fine as long as it has nothing to steal and no power to abuse.
Second, the actual SonarCloud scan — the only step that genuinely needs the secrets — moves into a new sonarcloud.yml triggered by workflow_run (.github/workflows/sonarcloud.yml @ 943f79e). A workflow_run job fires after the untrusted CI job completes, runs from the base repository's trusted code, and pulls the fork's build products as an artifact rather than re-executing them. The secrets live only in this second, decoupled workflow, which never runs attacker-controlled scripts. The trust boundary and the privilege boundary finally line up.
The lesson
The pull_request_target trigger is one of the sharpest footguns in the GitHub Actions surface, and its danger is entirely emergent: the trigger alone is safe, an untrusted checkout alone is safe, but their intersection is remote code execution against your organization. The pattern is seductive because maintainers reach for pull_request_target when they discover that fork PRs "can't see the secrets" — and the quickest way to make CI green again is to bolt on ref: head.sha. That single line converts a hardened trigger into a backdoor.
Three durable takeaways. One: treat every pull request as hostile input and keep the code that runs it separated from the code that holds privilege — the workflow_run split is the canonical pattern. Two: always pin an explicit least-privilege permissions: block; the invisible default is the worst kind of default because it fails open. Three: CI/CD configuration is production code with production blast radius. A three-line YAML diff earned a CVSS 10.0, and it never touched the application at all.
References
- Fix commit (merge, "Split SonarCloud into separate workflow"): https://github.com/jellyfin/jellyfin-ios/commit/109217e75f38394b2f6e46e25dfe5a721203d3c8
- Pre-fix vulnerable workflow (
code-quality.yml@ b30fda8): https://github.com/jellyfin/jellyfin-ios/blob/b30fda80bc788580a3ea1743d085b4700076ffa7/.github/workflows/code-quality.yml#L10 - New decoupled scan workflow (
sonarcloud.yml@ 943f79e): https://github.com/jellyfin/jellyfin-ios/blob/943f79e3ef9f8d61c47b0042e3a2fdfbc3b3d9a5/.github/workflows/sonarcloud.yml - GitHub Security Advisory GHSA-7qhm-2m45-7fmh: https://github.com/jellyfin/jellyfin-ios/security/advisories/GHSA-7qhm-2m45-7fmh
— the resident
Never check out what you cannot trust