Black: Arbitrary file writes from unsanitized user input in cache file name
Description
Black is the uncompromising Python code formatter. Prior to 26.3.1, Black writes a cache file, the name of which is computed from various formatting options. The value of the --python-cell-magics option was placed in the filename without sanitization, which allowed an attacker who controls the value of this argument to write cache files to arbitrary file system locations. Fixed in Black 26.3.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Black prior to 26.3.1 allows arbitrary file write via unsanitized --python-cell-magics option used in cache filename construction.
Vulnerability
Black, the uncompromising Python code formatter, prior to version 26.3.1 contains a vulnerability in its cache file naming mechanism. The cache filename is constructed from various formatting options, including the --python-cell-magics argument. However, this argument's value was incorporated into the filename without proper sanitization or validation [1]. This allows an attacker who controls or influences the --python-cell-magics value to perform path traversal and write cache files to arbitrary file system locations where the Black process has write permissions [2].
Exploitation
Exploitation requires the attacker to control the --python-cell-magics argument passed to Black. This could occur if Black is invoked programmatically with user-supplied arguments or via a CI/CD pipeline or other automated system where an attacker can inject command-line options. No authentication is needed beyond the ability to trigger Black with crafted arguments. The attack surface is limited to systems where Black is executed with untrusted input affecting the --python-cell-magics value [3].
Impact
A successful attack could allow an attacker to write cache files to arbitrary locations on the file system. Depending on the write location and file content (cache files contain internal formatting data), this could lead to data corruption, overwriting of configuration files, or potentially code execution if files are written to executable directories or startup locations. The severity is moderate, as it requires control over Black's arguments and has a specific attack vector [1][2].
Mitigation
The vulnerability is fixed in Black version 26.3.1. Users should upgrade to this version or later. There are no known workarounds for unpatched versions, as the fix involves sanitizing the cache filename input. The official Black repository contains the patch in commit 4937fe6, which applies validation to the --python-cell-magics value before use in cache file naming [4].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
blackPyPI | < 26.3.1 | 26.3.1 |
Affected products
2- psf/blackv5Range: < 26.3.1
Patches
14937fe6cf241Fix some shenanigans with the cache file and IPython (#5038)
5 files changed · +54 −10
CHANGES.md+7 −0 modified@@ -13,6 +13,10 @@ <!-- Changes that affect Black's stable style --> +- Prevent Jupyter notebook magic masking collisions from corrupting cells by using + exact-length placeholders for short magics and aborting if a placeholder can no longer + be unmasked safely (#5038) + ### Preview style <!-- Changes that affect Black's preview style --> @@ -21,6 +25,9 @@ <!-- Changes to how Black can be configured --> +- Always hash cache filename components derived from `--python-cell-magics` so custom + magic names cannot affect cache paths (#5038) + ### Packaging <!-- Changes to how Black is packaged, such as dependency requirements -->
src/black/handle_ipynb_magics.py+17 −4 modified@@ -5,6 +5,8 @@ import dataclasses import re import secrets +import string +from collections.abc import Collection from functools import lru_cache from importlib.util import find_spec from typing import TypeGuard @@ -188,6 +190,13 @@ def mask_cell(src: str) -> tuple[str, list[Replacement]]: def create_token(n_chars: int) -> str: """Create a randomly generated token that is n_chars characters long.""" assert n_chars > 0 + if n_chars == 1: + return secrets.choice(string.ascii_letters) + if n_chars < 4: + return "_" + "".join( + secrets.choice(string.ascii_letters + string.digits + "_") + for _ in range(n_chars - 1) + ) n_bytes = max(n_chars // 2 - 1, 1) token = secrets.token_hex(n_bytes) if len(token) + 3 > n_chars: @@ -197,7 +206,7 @@ def create_token(n_chars: int) -> str: return f'b"{token}"' -def get_token(src: str, magic: str) -> str: +def get_token(src: str, magic: str, existing_tokens: Collection[str] = ()) -> str: """Return randomly generated token to mask IPython magic with. For example, if 'magic' was `%matplotlib inline`, then a possible @@ -209,7 +218,7 @@ def get_token(src: str, magic: str) -> str: n_chars = len(magic) token = create_token(n_chars) counter = 0 - while token in src: + while token in src or token in existing_tokens: token = create_token(n_chars) counter += 1 if counter > 100: @@ -271,6 +280,7 @@ def replace_magics(src: str) -> tuple[str, list[Replacement]]: The replacement, along with the transformed code, are returned. """ replacements = [] + existing_tokens: set[str] = set() magic_finder = MagicFinder() magic_finder.visit(ast.parse(src)) new_srcs = [] @@ -286,8 +296,9 @@ def replace_magics(src: str) -> tuple[str, list[Replacement]]: offsets_and_magics[0].col_offset, offsets_and_magics[0].magic, ) - mask = get_token(src, magic) + mask = get_token(src, magic, existing_tokens) replacements.append(Replacement(mask=mask, src=magic)) + existing_tokens.add(mask) line = line[:col_offset] + mask new_srcs.append(line) return "\n".join(new_srcs), replacements @@ -307,7 +318,9 @@ def unmask_cell(src: str, replacements: list[Replacement]) -> str: foo = bar """ for replacement in replacements: - src = src.replace(replacement.mask, replacement.src) + if src.count(replacement.mask) != 1: + raise NothingChanged + src = src.replace(replacement.mask, replacement.src, 1) return src
src/black/mode.py+3 −4 modified@@ -288,10 +288,9 @@ def get_cache_key(self) -> str: + "@" + ",".join(sorted(self.python_cell_magics)) ) - if len(features_and_magics) > _MAX_CACHE_KEY_PART_LENGTH: - features_and_magics = sha256(features_and_magics.encode()).hexdigest()[ - :_MAX_CACHE_KEY_PART_LENGTH - ] + features_and_magics = sha256(features_and_magics.encode()).hexdigest()[ + :_MAX_CACHE_KEY_PART_LENGTH + ] parts = [ version_str, str(self.line_length),
tests/test_black.py+9 −0 modified@@ -2166,6 +2166,15 @@ def test_cache_file_length(self) -> None: # doesn't get too crazy. assert len(cache_file.name) <= 96 + def test_cache_file_path_ignores_python_cell_magic_separators(self) -> None: + mode = replace(DEFAULT_MODE, python_cell_magics={"../../../tmp/pwned"}) + with cache_dir() as workspace: + cache_file = get_cache_file(mode) + assert cache_file.parent == workspace + assert "/" not in cache_file.name + assert ".." not in cache_file.name + assert "../../../tmp/pwned" not in mode.get_cache_key() + def test_cache_broken_file(self) -> None: mode = DEFAULT_MODE with cache_dir() as workspace:
tests/test_ipynb.py+18 −2 modified@@ -6,8 +6,8 @@ from dataclasses import replace import pytest -from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner +from pytest import MonkeyPatch from black import ( Mode, @@ -17,7 +17,12 @@ format_file_in_place, main, ) -from black.handle_ipynb_magics import jupyter_dependencies_are_installed +from black.handle_ipynb_magics import ( + Replacement, + create_token, + jupyter_dependencies_are_installed, + unmask_cell, +) from tests.util import DATA_DIR, get_case_path, read_jupyter_notebook with contextlib.suppress(ModuleNotFoundError): @@ -39,6 +44,17 @@ def test_noop() -> None: format_cell(src, fast=True, mode=JUPYTER_MODE) +@pytest.mark.parametrize("n_chars", [1, 2, 3, 4, 5, 17]) +def test_create_token_uses_requested_length(n_chars: int) -> None: + assert len(create_token(n_chars)) == n_chars + + +def test_unmask_cell_raises_when_token_is_not_unique() -> None: + replacement = Replacement(mask='b"dead"', src="%time") + with pytest.raises(NothingChanged): + unmask_cell(f"{replacement.mask}\nvalue = {replacement.mask}", [replacement]) + + @pytest.mark.parametrize("fast", [True, False]) def test_trailing_semicolon(fast: bool) -> None: src = 'foo = "a" ;'
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-3936-cmfr-pm3mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32274ghsaADVISORY
- github.com/psf/black/commit/4937fe6cf241139ddbfc16b0bdbb5b422798909dghsax_refsource_MISCWEB
- github.com/psf/black/pull/5038ghsax_refsource_MISCWEB
- github.com/psf/black/releases/tag/26.3.1ghsax_refsource_MISCWEB
- github.com/psf/black/security/advisories/GHSA-3936-cmfr-pm3mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.