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 →

Tested on 24 October 2026. Updating brotli and brotlicffi fixes the first two paths; the bundled copies in pyarrow and Pillow need their own releases.

What the bug is

Details
CVECVE-2026-111497
WhereThe Brotli C decoder, c/dec/decode.c, versions 1.0.3 to 1.2.0
ImpactOut-of-bounds write: memory corruption, data disclosure, crashes, possibly remote code execution
Also calledNode2Shell, HTTP2Shell
Severity9.2 out of 10, Critical (CVSS 4.0, GitHub advisory); High in the Node.js advisory
Upstream fixBrotli 1.2.1, 1.1.1 and 1.0.10, released 21 October 2026
Python advisoriesGHSA-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

PackageAffectedFixedNotes
brotli1.0.3 to 1.2.01.2.1PyPI has no 1.1.x or 1.0.x fix, so 1.2.1 is the only fixed wheel
brotlicffi1.0.3 to 1.2.0.x1.2.1.0Used on PyPy and by some installs on CPython
pyarrow26.0.0 and earlier (PyPI wheels)No fixed wheel yetArrow's bundled build pins Brotli 1.0.9 (issue #55166); conda-forge builds are fixed
Pillow13.0.0 Linux and macOS wheelsNo fixed wheel yetShips libbrotlidec 1.2.0 for FreeType's WOFF2 support; the Windows wheel has no Brotli
python3-brotliDebian and Ubuntu system packageDebian: 1.0.9-2+deb12u1, 1.1.0-2+deb13u1Ubuntu 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 2.10.1, 24 October 2026
$ 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 flagged

Dependabot 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.

Output (requests 2.34.2, httpx 0.28.1, aiohttp 3.14.5, Flask, FastAPI; trimmed)
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: br request 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 brotli or brotlicffi is installed: requests (through urllib3), httpx and the aiohttp client all offered br and 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 br on 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 brotli package.
  • 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
"""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.

brotli_check.py
#!/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:]))
Terminal
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 needed
Output (Windows, brotli updated, pyarrow installed; trimmed)
Brotli 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:

python3 brotli_check.py /usr/lib/x86_64-linux-gnu (Ubuntu 22.04)
Compiled files that bundle the Brotli C decoder:
  VULNERABLE /usr/lib/x86_64-linux-gnu/libbrotlidec.so.1.0.9  [outside Python packages]
Debian packages, summarised
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)
test_brotli_check.py
"""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

Terminal
# 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
# 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.txt

If your app uses the distribution's Python packages or system library:

Terminal
# 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
  1. 1
    Rebuild container images after updating the lock file or base image; a running container keeps its old files.
  2. 2
    Restart every Python process (web workers, Celery workers, scheduled jobs): a loaded extension stays in memory until the process exits.
  3. 3
    Run 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):

app.py
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})
Output of aiohttp_mitigation.py
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)
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
"""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)
Terminal
$ 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 0

If you install pyarrow with conda, you can fix it today:

Terminal
# 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?+
Yes. CVE-2026-111497 is in Google's Brotli C library, which the PyPI packages brotli and brotlicffi wrap. Update brotli to 1.2.1 and brotlicffi to 1.2.1.0, and check pyarrow and Pillow, which bundle their own copies.
Does requests use Brotli?+
Only if brotli or brotlicffi is installed. Then requests, through urllib3, sends Accept-Encoding: gzip, deflate, br and decodes Brotli responses automatically. Without either package it does not offer or decode Brotli.
Do pip-audit and Dependabot detect CVE-2026-111497?+
Both flag brotli below 1.2.1 and brotlicffi below 1.2.1.0. Neither flags pyarrow 26.0.0 or Pillow 13.0.0, which bundle unpatched copies, because there is no advisory for those packages and scanners only see package versions. Use a file-level check such as the one in this guide.
Are FastAPI and Flask apps affected?+
Their request handling did not decode Brotli bodies in tests. They are still affected if the app makes outgoing requests with requests, httpx or aiohttp while brotli is installed, or processes files with pyarrow or Pillow.
Can I just uninstall brotli?+
As a short-term stop-gap, yes: requests, httpx and aiohttp stop offering and decoding Brotli without it. It does nothing for the copies inside pyarrow and Pillow, and updating to 1.2.1 is simpler and safer.

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

← All guides