Guide
Node2Shell in Python: the Brotli bug (CVE-2026-111497) in requests, aiohttp, pyarrow and Pillow
By Aman Singh · Published · 11 min read
CVE-2026-111497, nicknamed Node2Shell or HTTP2Shell and scored 9.2 out of 10 (Critical), started as a Node.js headline, but the bug lives in Google's Brotli C library, and Python uses that library too. The brotli and brotlicffi packages on PyPI wrap it, and requests, httpx, aiohttp and Scrapy decode Brotli responses with it whenever one of them is installed. Two popular packages, pyarrow and Pillow, also ship their own bundled copies that updating brotli does not touch.
This guide covers the Python side: the fixed versions, what is reachable in a typical app, a checker that finds every copy of the decoder in an environment (including copies that pip-audit misses), and stop-gaps where no fix exists yet. Everything below was tested on 24 October 2026 on Windows 11 and Ubuntu 22.04 with Python 3.10 and 3.11. If you also run Node.js, see the Node.js Brotli guide; for Go, Java, .NET, PHP, Ruby, Linux and proxies, see the guide to every affected language and server.
Running Go, Java, .NET, PHP or Docker images too? Brotli status for every language →
What the bug is
| Details | |
|---|---|
| CVE | CVE-2026-111497 |
| Where | The Brotli C decoder, c/dec/decode.c, versions 1.0.3 to 1.2.0 |
| Impact | Out-of-bounds write: memory corruption, data disclosure, crashes, possibly remote code execution |
| Also called | Node2Shell, HTTP2Shell |
| Severity | 9.2 out of 10, Critical (CVSS 4.0, GitHub advisory); High in the Node.js advisory |
| Upstream fix | Brotli 1.2.1, 1.1.1 and 1.0.10, released 21 October 2026 |
| Python advisories | GHSA-9v8h-qqgm-4m35, PYSEC-2026-4322 (brotli), PYSEC-2026-4323 (brotlicffi) |
| Exploited? | No exploitation in the wild reported as of 23 October |
The cause is small. A Brotli stream can ask the decoder to switch to a different "block type". When the stream has declared only one block type, that request is invalid, but the old decoder answered it with the same FALSE it uses for "I need more input". The decoder then carried on in an inconsistent state and could write past the end of an internal buffer. The fix returns a new, distinct error, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, so the stream is rejected at once.
Affected packages and fixed versions
| Package | Affected | Fixed | Notes |
|---|---|---|---|
brotli | 1.0.3 to 1.2.0 | 1.2.1 | PyPI has no 1.1.x or 1.0.x fix, so 1.2.1 is the only fixed wheel |
brotlicffi | 1.0.3 to 1.2.0.x | 1.2.1.0 | Used on PyPy and by some installs on CPython |
pyarrow | 26.0.0 and earlier (PyPI wheels) | No fixed wheel yet | Arrow's bundled build pins Brotli 1.0.9 (issue #55166); conda-forge builds are fixed |
Pillow | 13.0.0 Linux and macOS wheels | No fixed wheel yet | Ships libbrotlidec 1.2.0 for FreeType's WOFF2 support; the Windows wheel has no Brotli |
python3-brotli | Debian and Ubuntu system package | Debian: 1.0.9-2+deb12u1, 1.1.0-2+deb13u1 | Ubuntu still lists it as needs-triage |
One point to check before trusting your scanner: as of 24 October, pip-audit does flag brotli and brotlicffi, but it has no way to see the copies inside other packages. In this run, pyarrow and Pillow were in the same requirements file and passed:
$ pip-audit -r requirements.txt --no-deps --disable-pip
Name Version ID Fix Versions
---------- ------- ------------------- ------------
brotli 1.2.0 PYSEC-2026-4322 1.2.1
brotli 1.2.0 GHSA-9v8h-qqgm-4m35 1.2.1
brotlicffi 1.2.0.2 PYSEC-2026-4323 1.2.1.0
# pyarrow==26.0.0 and pillow==13.0.0 were in the same file: not flaggedDependabot has the same blind spot. It reads the same GitHub advisory, so it does alert on brotli and brotlicffi in your requirements or lock files, but there is no advisory for the copies inside pyarrow or Pillow, so it stays quiet about those. A green Dependabot page does not mean you are safe here.
Which Python code paths are reachable
The attack works in both directions: a malicious client can take over an unpatched server by sending it a Brotli-compressed request, and a malicious server can take over an unpatched client by sending it a Brotli-compressed response.
The decoder is only dangerous when it decodes data an attacker controls. To see which common code paths do that, I ran each one against a local server that always answers with a harmless, valid Brotli body, and sent the same body to each web framework. The test shows whether outside data would reach the decoder, not the bug itself.
brotli installed: True brotlicffi installed: True
no urllib.request (stdlib) sent Accept-Encoding: identity
DECODES BROTLI requests sent Accept-Encoding: gzip, deflate, br
DECODES BROTLI httpx sent Accept-Encoding: gzip, deflate, br
DECODES BROTLI aiohttp client sent Accept-Encoding: gzip, deflate, br
DECODES BROTLI aiohttp web request.json() HTTP 200 {"got": "brotli"}
no Flask request.get_json() HTTP 200 {"error":"not JSON"}
no FastAPI request.json() HTTP 200 {"error":"not JSON"}
brotli installed: False brotlicffi installed: False
no requests sent Accept-Encoding: gzip, deflate
no httpx sent Accept-Encoding: gzip, deflate
no aiohttp web request.json() HTTP 400 Can not decode content-encoding: brotli- The aiohttp web server is the most exposed. It decodes
Content-Encoding: brrequest bodies from any client before your handler, authentication or middleware runs. Flask and FastAPI handed the raw bytes to the app. - Outgoing requests decode Brotli automatically once
brotliorbrotlicffiis installed: requests (through urllib3), httpx and the aiohttp client all offeredbrand decoded the reply. This matters when the server is not yours: webhooks you call, link previews, scrapers, anything that fetches user-supplied URLs. - Scrapy depends on brotli and offers
bron every request, so every site it crawls can send Brotli data. - Files count too. pyarrow decodes Brotli-compressed Parquet and Arrow IPC data with its bundled copy; Pillow's FreeType decodes WOFF2 fonts; fontTools decodes WOFF2 with the
brotlipackage. - Without either package, nothing decoded Brotli. requests and httpx stopped offering it, and the aiohttp server answered 400.
Show the test script (surface.py)
"""surface.py: which Python HTTP code paths hand outside Brotli data to the decoder?
Uses a harmless, valid Brotli payload, so it shows whether attacker data would
reach the decoder, not the bug itself. Run it in the environment you want to test.
"""
import asyncio
import http.server
import importlib.util
import json
import threading
import urllib.request
BODY = bytes.fromhex("8b07807b22676f74223a2262726f746c69227d03") # brotli of {"got":"brotli"}
HAS = {m: importlib.util.find_spec(m) is not None for m in ("brotli", "brotlicffi", "requests", "httpx", "aiohttp", "flask", "fastapi")}
class AlwaysBrotli(http.server.BaseHTTPRequestHandler):
"""Answers every request with a Brotli body, whatever the client asked for."""
def do_GET(self):
self.server.last_accept = self.headers.get("Accept-Encoding", "(none)")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Encoding", "br")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, *args):
pass
def decoded(data):
try:
return "DECODES BROTLI" if json.loads(data) == {"got": "brotli"} else "no"
except ValueError:
return "no"
def outbound():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), AlwaysBrotli)
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{server.server_address[1]}/"
rows = []
def row(name, get):
try:
result = decoded(get())
except Exception as err: # a client that cannot decode br may raise instead
result = f"no ({type(err).__name__})"
rows.append(f"{result:15} {name:28} sent Accept-Encoding: {server.last_accept}")
row("urllib.request (stdlib)", lambda: urllib.request.urlopen(url).read())
if HAS["requests"]:
import requests
row("requests", lambda: requests.get(url).content)
if HAS["httpx"]:
import httpx
row("httpx", lambda: httpx.get(url).content)
if HAS["aiohttp"]:
import aiohttp
async def fetch():
async with aiohttp.ClientSession() as s, s.get(url) as r:
return await r.read()
row("aiohttp client", lambda: asyncio.run(fetch()))
server.shutdown()
return rows
def inbound():
rows = []
headers = {"Content-Type": "application/json", "Content-Encoding": "br"}
if HAS["aiohttp"]:
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
async def handler(request):
return web.json_response(await request.json())
async def run():
app = web.Application()
app.router.add_post("/", handler)
async with TestClient(TestServer(app)) as client:
r = await client.post("/", data=BODY, headers=headers)
return r.status, await r.text()
status, text = asyncio.run(run())
rows.append(f"{decoded(text.encode()) if status == 200 else 'no':15} aiohttp web request.json() HTTP {status} {text[:40]}")
if HAS["flask"]:
from flask import Flask, request
app = Flask(__name__)
@app.post("/")
def echo():
return request.get_json(silent=True) or {"error": "not JSON"}
r = app.test_client().post("/", data=BODY, headers=headers)
rows.append(f"{decoded(r.data.strip()) if r.status_code == 200 else 'no':15} Flask request.get_json() HTTP {r.status_code} {r.data.decode(errors='replace').strip()[:40]}")
if HAS["fastapi"]:
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient as FastClient
app = FastAPI()
@app.post("/")
async def echo(request: Request):
try:
return await request.json()
except ValueError:
return {"error": "not JSON"}
r = FastClient(app).post("/", content=BODY, headers=headers)
rows.append(f"{decoded(r.content) if r.status_code == 200 else 'no':15} FastAPI request.json() HTTP {r.status_code} {r.text[:40]}")
return rows
if __name__ == "__main__":
print("brotli installed:", HAS["brotli"], " brotlicffi installed:", HAS["brotlicffi"])
print("\n".join(outbound() + inbound()))Check an environment
Version numbers only tell part of the story, because pyarrow and Pillow do not report which Brotli they bundle and Linux distributions patch without changing the version. The checker below looks inside the compiled files instead. The fix added a new error name to the decoder's string table, so a file that contains the decoder's error names but not the new one is unpatched. I confirmed this against every build in this guide: the Python wheels, five official Node.js builds, and Debian's packages before and after the security update.
Run it with the same python that runs your app (the one in your virtualenv, container or service file). It needs only the standard library.
#!/usr/bin/env python3
"""brotli_check.py: find copies of the Brotli C decoder hit by CVE-2026-111497.
Checks the Brotli and brotlicffi packages, then scans every compiled file in
this Python's site-packages (plus any folders you pass) for a bundled copy of
the Brotli C decoder, and reports which copies have the October 2026 fix.
python brotli_check.py # this environment
python brotli_check.py /usr/lib /opt/myapp # also scan these folders
Exit code: 0 = no vulnerable copy found, 1 = at least one needs updating.
Standard library only; Python 3.8+.
"""
import importlib.metadata as md
import mmap
import os
import re
import site
import sys
# The fix added a new decoder error code. Its name only exists in fixed builds
# (including distribution backports that keep the old version number), while
# the older error names exist in every build since 1.0. Brotli 1.0.x stores
# them as "PADDING_1", 1.1 and later as "_ERROR_FORMAT_PADDING_1". Matched in
# the exact form the C library stores them, so Rust and Go ports are not counted.
OLD_NAME = re.compile(rb"(?<![A-Za-z0-9_])(?:_ERROR_FORMAT_)?PADDING_1\x00")
FIX_NAME = re.compile(rb"(?<![A-Za-z0-9_])(?:_ERROR_FORMAT_)?BLOCK_SWITCH\x00")
BINARY = re.compile(r"\.(so(\.\d+)*|pyd|dll|dylib)$", re.IGNORECASE)
FIXED_IN = {"brotli": (1, 2, 1), "brotlicffi": (1, 2, 1, 0)}
# What each installed package does with Brotli, from tests on 24 October 2026.
DECODES = {
"urllib3": "requests and urllib3 offer br and decode Brotli responses when brotli or brotlicffi is installed.",
"httpx": "httpx offers br and decodes Brotli responses when brotli or brotlicffi is installed.",
"aiohttp": "aiohttp decodes Brotli responses, and its web server decodes Brotli request bodies from any client.",
"scrapy": "Scrapy offers br on every request and decodes Brotli responses from the sites it crawls.",
"fonttools": "fontTools decodes WOFF2 fonts with the brotli package.",
"pyarrow": "pyarrow decodes Brotli-compressed Parquet and Arrow IPC data with its own bundled copy.",
"pillow": "Pillow's bundled FreeType decodes WOFF2 fonts with a bundled libbrotlidec (Linux and macOS wheels).",
}
BUNDLED = {"pyarrow", "pillow"} # decode with their own copy, not the brotli package
def parse_version(text):
return tuple(int(p) for p in re.findall(r"\d+", text)[:4])
def package_status(name, version):
"""VULNERABLE, FIXED or n/a for a Brotli binding at this version."""
v = parse_version(version)
if v < (1, 0, 3):
return "n/a (older than the affected range)"
return "FIXED" if v >= FIXED_IN[name] else "VULNERABLE"
def classify_file(path):
"""Returns 'fixed', 'vulnerable' or None when the file holds no readable copy
of the Brotli C decoder. Builds that strip the error names (some Windows
DLLs) are not detected: check those by their package version."""
try:
size = os.path.getsize(path)
if size == 0:
return None
with open(path, "rb") as fh, mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) as data:
if not OLD_NAME.search(data):
return None
return "fixed" if FIX_NAME.search(data) else "vulnerable"
except (OSError, ValueError):
return None
def owners():
"""Maps absolute file paths to the installed distribution that owns them."""
result = {}
for dist in md.distributions():
name = dist.metadata["Name"]
for f in dist.files or []:
if BINARY.search(str(f)):
result[os.path.normcase(os.path.realpath(dist.locate_file(f)))] = f"{name} {dist.version}"
return result
def scan(folders):
"""Yields (path, status) for every binary under the folders that bundles the decoder."""
seen = set()
for folder in folders:
for root, _dirs, files in os.walk(folder):
for name in files:
if not BINARY.search(name):
continue
path = os.path.normcase(os.path.realpath(os.path.join(root, name)))
if path in seen:
continue
seen.add(path)
status = classify_file(path)
if status:
yield path, status
def main(argv):
print("Brotli CVE-2026-111497 check")
print(f"python: {sys.version.split()[0]} ({sys.executable})")
problems = 0
print("\nBrotli packages:")
installed = {d.metadata["Name"].lower(): d.version for d in md.distributions()}
for name in FIXED_IN:
if name in installed:
status = package_status(name, installed[name])
problems += status == "VULNERABLE"
want = ".".join(map(str, FIXED_IN[name]))
print(f" {status:10} {name} {installed[name]}" + ("" if status != "VULNERABLE" else f" (update to {want} or later)"))
else:
print(f" - {name} not installed")
folders = [p for p in dict.fromkeys(site.getsitepackages() + [site.getusersitepackages()]) if os.path.isdir(p)]
folders += [p for p in argv if os.path.isdir(p)]
who = owners()
print("\nCompiled files that bundle the Brotli C decoder:")
found = False
for path, status in scan(folders):
found = True
problems += status == "vulnerable"
label = "FIXED" if status == "fixed" else "VULNERABLE"
print(f" {label:10} {path} [{who.get(path, 'outside Python packages')}]")
if not found:
print(" none found")
# These only decode Brotli through the brotli or brotlicffi package.
binding = any(name in installed for name in FIXED_IN)
present = [k for k in DECODES if k in installed and (binding or k in BUNDLED)]
if present:
print("\nWhere this environment decodes Brotli from outside:")
for k in present:
print(f" - {DECODES[k]}")
if problems:
print("\nACTION NEEDED: update the packages marked VULNERABLE, then restart the app.")
print("Where no fixed release exists yet, keep untrusted Brotli data away from that package.")
else:
print("\nOK: no vulnerable copy of the Brotli C decoder found.")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))python brotli_check.py # the environment this python runs
python brotli_check.py /usr/lib # also scan a folder (system libraries, an app)
echo "exit code: $?" # 0 = nothing vulnerable, 1 = action neededBrotli CVE-2026-111497 check
python: 3.11.3 (.venv\Scripts\python.exe)
Brotli packages:
FIXED brotli 1.2.1
FIXED brotlicffi 1.2.1.0
Compiled files that bundle the Brotli C decoder:
FIXED .venv\lib\site-packages\brotlicffi\_brotlicffi.pyd [brotlicffi 1.2.1.0]
VULNERABLE .venv\lib\site-packages\pyarrow\arrow.dll [pyarrow 26.0.0]
Where this environment decodes Brotli from outside:
- requests and urllib3 offer br and decode Brotli responses when brotli or brotlicffi is installed.
- aiohttp decodes Brotli responses, and its web server decodes Brotli request bodies from any client.
- pyarrow decodes Brotli-compressed Parquet and Arrow IPC data with its own bundled copy.
ACTION NEEDED: update the packages marked VULNERABLE, then restart the app.
Where no fixed release exists yet, keep untrusted Brotli data away from that package.Pointed at a folder, it checks system libraries too. On a stock Ubuntu 22.04 (WSL) it found the system library still unpatched, and run against Debian's packages it told the old and fixed builds apart even though their version numbers are the same:
Compiled files that bundle the Brotli C decoder:
VULNERABLE /usr/lib/x86_64-linux-gnu/libbrotlidec.so.1.0.9 [outside Python packages]VULNERABLE libbrotli1 1.0.9-2+b6 (bookworm, before the fix)
FIXED libbrotli1 1.0.9-2+deb12u1 (bookworm security update)
VULNERABLE libbrotli1 1.1.0-2+b7 (trixie, before the fix)
FIXED libbrotli1 1.1.0-2+deb13u1 (trixie security update)Show the tests (test_brotli_check.py, 8 passing)
"""Tests for brotli_check.py. Run: python -m unittest test_brotli_check.py"""
import os
import tempfile
import unittest
import brotli_check as bc
C_OLD = b"\x00_ERROR_FORMAT_PADDING_1\x00"
C_FIX = b"\x00_ERROR_FORMAT_BLOCK_SWITCH\x00"
def write(folder, name, data):
path = os.path.join(folder, name)
with open(path, "wb") as fh:
fh.write(b"\x7fELF" + b"\x00" * 64 + data + b"\x00" * 64)
return path
class ClassifyTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def test_old_decoder_is_vulnerable(self):
self.assertEqual(bc.classify_file(write(self.tmp.name, "a.so", C_OLD)), "vulnerable")
def test_fixed_decoder_has_the_new_error_name(self):
self.assertEqual(bc.classify_file(write(self.tmp.name, "b.so", C_OLD + C_FIX)), "fixed")
def test_brotli_1_0_short_names(self):
old = b"\x00WINDOW_BITS\x00PADDING_1\x00PADDING_2\x00DISTANCE\x00"
self.assertEqual(bc.classify_file(write(self.tmp.name, "d.so", old)), "vulnerable")
self.assertEqual(bc.classify_file(write(self.tmp.name, "e.so", old + b"BLOCK_SWITCH\x00")), "fixed")
def test_rust_and_node_names_are_not_the_c_decoder(self):
rust = b"ERROR_FORMAT_PADDING_1\x00BROTLI_DECODER_ERROR_FORMAT_PADDING_1\x00"
self.assertIsNone(bc.classify_file(write(self.tmp.name, "c.so", rust)))
def test_imported_function_names_are_not_a_bundled_decoder(self):
# libcurl and .NET's managed wrapper only reference the decoder by name.
path = write(self.tmp.name, "f.so", b"BrotliDecoderDecompressStream\x00BrotliDecoderCreateInstance\x00")
self.assertIsNone(bc.classify_file(path))
def test_scan_only_reports_files_with_a_decoder(self):
write(self.tmp.name, "with.pyd", C_OLD)
write(self.tmp.name, "without.dll", b"nothing here")
write(self.tmp.name, "notes.txt", C_OLD) # not a compiled file
found = [(os.path.basename(p), s) for p, s in bc.scan([self.tmp.name])]
self.assertEqual(found, [("with.pyd", "vulnerable")])
class VersionTests(unittest.TestCase):
def test_brotli_versions(self):
self.assertEqual(bc.package_status("brotli", "1.2.0"), "VULNERABLE")
self.assertEqual(bc.package_status("brotli", "1.0.9"), "VULNERABLE")
self.assertEqual(bc.package_status("brotli", "1.2.1"), "FIXED")
self.assertTrue(bc.package_status("brotli", "1.0.2").startswith("n/a"))
def test_brotlicffi_versions(self):
self.assertEqual(bc.package_status("brotlicffi", "1.2.0.2"), "VULNERABLE")
self.assertEqual(bc.package_status("brotlicffi", "1.2.1.0"), "FIXED")
if __name__ == "__main__":
unittest.main()Fix it
# pip
pip install -U "brotli>=1.2.1" "brotlicffi>=1.2.1.0"
# uv
uv lock --upgrade-package brotli --upgrade-package brotlicffi && uv sync
# Poetry
poetry update brotli brotlicffi
# Then confirm what is actually installed
pip show brotli brotlicffi | grep -E "^(Name|Version)"Few projects list brotli directly; it usually comes in through urllib3[brotli], aiohttp[speedups], httpx[brotli] or Scrapy, and a lock file can keep the old version. A constraint makes sure the fixed one wins:
# constraints.txt: brotli usually arrives through urllib3[brotli],
# aiohttp[speedups], httpx[brotli] or Scrapy, so pin it here
brotli>=1.2.1
brotlicffi>=1.2.1.0
# pip install -r requirements.txt -c constraints.txtIf your app uses the distribution's Python packages or system library:
# Debian (bookworm, trixie): fixes libbrotli1 and python3-brotli
sudo apt-get update && sudo apt-get install --only-upgrade libbrotli1 python3-brotli
# Alpine 3.21 and later
apk upgrade brotli-libs- 1Rebuild container images after updating the lock file or base image; a running container keeps its old files.
- 2Restart every Python process (web workers, Celery workers, scheduled jobs): a loaded extension stays in memory until the process exits.
- 3Run the checker again where the code runs and confirm nothing says VULNERABLE.
Stop-gaps where there is no fix yet
aiohttp servers. I first tried a middleware that rejects Content-Encoding: br. It returned 415 as intended, but counting calls into the decoder showed the body had already been decoded: aiohttp decompresses the body as it arrives, before middleware runs. Turning off automatic decompression is what keeps the body away from the decoder (it also turns off gzip and deflate decoding, so only use it if your clients do not compress request bodies):
from aiohttp import web
# Before: decodes Brotli (and gzip, deflate) request bodies automatically
app = web.Application()
# Stop-gap: request bodies reach your handler still compressed
app = web.Application(handler_args={"auto_decompress": False})default valid brotli decoder calls: 1 HTTP 200 {"got": "brotli"}
default invalid brotli decoder calls: 1 HTTP 400 {"error": "RequestPayloadError"}
middleware rejecting br valid brotli decoder calls: 1 HTTP 415 {"error": "Brotli request bodies are not accepte
middleware rejecting br invalid brotli decoder calls: 1 HTTP 415 {"error": "Brotli request bodies are not accepte
auto_decompress=False valid brotli decoder calls: 0 HTTP 400 {"error": "UnicodeDecodeError"}
auto_decompress=False invalid brotli decoder calls: 0 HTTP 400 {"error": "UnicodeDecodeError"}Show the test script (aiohttp_mitigation.py)
"""Can an aiohttp app keep Brotli request bodies away from the decoder?
Counts calls into aiohttp's Brotli decoder for a valid and an invalid Brotli
request body, with and without each stop-gap. Zero calls means the body never
reached the decoder.
"""
import asyncio
from aiohttp import compression_utils, web
from aiohttp.test_utils import TestClient, TestServer
# Count every call into aiohttp's Brotli decoder.
CALLS = {"n": 0}
_original = compression_utils.BrotliDecompressor.decompress_sync
def counted(self, *args, **kwargs):
CALLS["n"] += 1
return _original(self, *args, **kwargs)
compression_utils.BrotliDecompressor.decompress_sync = counted
BROTLI = bytes.fromhex("8b07807b22676f74223a2262726f746c69227d03")
JUNK = b"\xff" * 64 # not valid Brotli
async def echo(request):
try:
return web.json_response(await request.json())
except Exception as err:
return web.json_response({"error": type(err).__name__}, status=400)
@web.middleware
async def reject_brotli(request, handler):
if "br" in request.headers.get("Content-Encoding", "").lower():
return web.json_response({"error": "Brotli request bodies are not accepted"}, status=415)
return await handler(request)
async def run(label, app):
app.router.add_post("/", echo)
async with TestClient(TestServer(app)) as client:
for name, body in (("valid brotli", BROTLI), ("invalid brotli", JUNK)):
CALLS["n"] = 0
r = await client.post("/", data=body, headers={"Content-Type": "application/json", "Content-Encoding": "br"})
text = (await r.text())[:48]
print(f"{label:26} {name:15} decoder calls: {CALLS['n']} HTTP {r.status} {text}")
async def main():
await run("default", web.Application())
await run("middleware rejecting br", web.Application(middlewares=[reject_brotli]))
await run("auto_decompress=False", web.Application(handler_args={"auto_decompress": False}))
asyncio.run(main())pyarrow. Until a fixed wheel is out, do not open Parquet or Arrow files from untrusted sources with pyarrow from PyPI if they use Brotli. The codec is in the file's footer, and reading the footer does not decompress anything, so you can check first:
"""parquet_codecs.py: list the compression codecs in a Parquet file without decompressing it.
Reading the footer metadata does not touch compressed pages, so this is safe to
run on an untrusted file before deciding whether to read it.
python parquet_codecs.py file.parquet # exit 1 if any column uses BROTLI
"""
import sys
import pyarrow.parquet as pq
def codecs(path):
meta = pq.ParquetFile(path).metadata
return {
meta.row_group(g).column(c).compression
for g in range(meta.num_row_groups)
for c in range(meta.num_columns)
}
if __name__ == "__main__":
found = codecs(sys.argv[1])
print("codecs:", ", ".join(sorted(found)))
sys.exit(1 if "BROTLI" in found else 0)$ python parquet_codecs.py upload.parquet
codecs: BROTLI # exit code 1: do not open it with this pyarrow
$ python parquet_codecs.py other.parquet
codecs: SNAPPY # exit code 0If you install pyarrow with conda, you can fix it today:
# conda-forge's libarrow links the shared libbrotlidec package,
# so updating that one package fixes pyarrow 25 and 26
conda install -c conda-forge "libbrotlidec>=1.2.1"Pillow. Do not load WOFF2 fonts (files starting with wOF2) from users with ImageFont.truetype() until a fixed wheel is released. Ordinary images are not affected.
Common questions
Is Python affected by Node2Shell, the Node.js Brotli vulnerability?+
Does requests use Brotli?+
Do pip-audit and Dependabot detect CVE-2026-111497?+
Are FastAPI and Flask apps affected?+
Can I just uninstall brotli?+
Want your Python services checked?
I update Python dependencies and base images, fix what breaks, and verify the result where the code runs. See Node.js, Python and Next.js bug fixing.
Sources
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call