high · 7.5 Patched

Django REST framework: Accept header with many large quoted parameters burns ~19s of CPU per request in content negotiation

CVE-2026-114138 · GHSA-33wh-fxxf-88vv · published 22 Oct 2026

How serious is it

  • Severity: high, CVSS score 7.5 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-114138?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • djangorestframework (PyPI) < 3.18.2

How to check

  1. Run pip show djangorestframework in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. Upgrade djangorestframework to 3.18.2 or later, commit the lockfile and redeploy.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

### Summary DRF's content negotiation parses each Accept token several times per request and never limits the number of tokens or their total size. Each parse ends up in Django's parse_header_parameters, which takes quadratic time when a quoted parameter value is packed with semicolons. On my machine a single GET with a 780KB Accept header made of 96 such tokens kept one gunicorn worker busy for 18.87 seconds (Django 6.1, djangorestframework 3.18.0, gunicorn 26.0.0, Python 3.13, Docker on macOS). The header is syntactically valid, fits inside gunicorn's default header limits, and needs no authentication. Two concurrent requests, which is one per sync worker, made a default two worker deployment stop answering for my whole 40 second test window. It recovered half a second after I stopped sending. ### Details #### What happens get_accept_list() splits the Accept header on commas with no cap on token count or total size, at rest_framework/negotiation.py:96-97. For every token, select_renderer() calls media_type_matches() once per renderer, and each call builds fresh _MediaType objects for both sides, at rest_framework/negotiation.py:53-56 and rest_framework/utils/mediatypes.py:22-23. `_MediaType.__init__` calls django.utils.http.parse_header_parameters unconditionally, at rest_framework/utils/mediatypes.py:47. When nothing matches, DRF raises NotAcceptable (406). Rendering that error response also needs a renderer, so finalize_response() runs the entire negotiation a second time through perform_content_negotiation(force=True), at rest_framework/views.py:434-437. With the default two renderers this comes to about six parses per Accept token per request. I confirmed the count by instrumenting _MediaType with a counter: six per token, three from the normal pass and three from the forced re-negotiation. The quadratic part is on the Django side. parse_header_parameters splits parameters with _parseparam, which handles quoted values by recounting the quote characters from the start of the string for every candidate separator, the s.count('"', 0, end) calls in django/utils/http.py. A quoted value holding k semicolons costs roughly k rescans over k characters, so a single ~8KB value takes about 31ms per parse here. Numbers from my runs, measured as utime+stime deltas of the server process: one token, ~9KB: 230ms server CPU 24 tokens, 216KB: 5.07s on runserver, which has no per field limit 48 tokens, 432KB: 10.08s on runserver 96 tokens, 780KB: 18.87s under gunicorn defaults Wall time matched CPU time in every run, so this is pure CPU work. RSS stayed flat at 57MB throughout, this is a time attack and not a memory one. #### A few details that matter for reproducing it: Django rejects a single media type string longer than 10,000 chars (MAX_HEADER_LENGTH), so each token has to stay just under that. Going over gives you a ValueError and a 500, which is a different and much weaker problem. gunicorn defaults are limit_request_field_size 8190 and limit_request_fields 100, so the working shape is 96 separate Accept fields with one ~8.1KB token each. That puts the single request ceiling near 19s, below the default 30s worker timeout, so one request alone does not get a stock gunicorn worker killed by the arbiter. It does not need to. Each request pins one sync worker for its full duration, so on two workers, two held connections wedge the service. order_by_precedence collects tokens into a set, so identical tokens collapse and the cost mostly disappears. Each token needs its own parameter name, x0=, x1=, x2= and so on. #### Where a fix could go, either of these alone would be enough: Parse each Accept token once per request and reuse the result in the matching loop and in the 406 re-negotiation. Put an aggregate budget in get_accept_list(), say 8KB total or 64 tokens, and treat anything past it as not acceptable. The quadratic behavior itself lives in Django's parser, but DRF is the one calling it six times per token with no cap on how many tokens there are, so both fixes look like DRF changes to me. ### PoC Verified end to end on Docker Desktop for macOS (Apple Silicon, 8 cores), python:3.13-slim image, Django 6.1, djangorestframework 3.18.0, gunicorn 26.0.0. Nothing was configured beyond defaults. Minimal target, any default DRF project works, this is the one I used. requirements.txt: django==6.1 djangorestframework==3.18.0 gunicorn==26.0.0 pocproj/settings.py: ```python SECRET_KEY = 'poc'; DEBUG = False; ALLOWED_HOSTS = ['*'] INSTALLED_APPS = ['django.contrib.contenttypes', 'django.contrib.auth', 'rest_framework'] ROOT_URLCONF = 'pocproj.urls'; WSGI_APPLICATION = 'pocproj.wsgi.application' DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}} USE_TZ = True ``` pocproj/api.py: ```python from rest_framework.views import APIView from rest_framework.response import Response class Items(APIView): def get(self, request): return Response([{'id': 1, 'name': 'ok'}]) ``` pocproj/urls.py: ```python from django.urls import path from .api import Items urlpatterns = [path('items/', Items.as_view())] ``` Start it: python manage.py migrate --noinput gunicorn pocproj.wsgi:application --bind 0.0.0.0:8000 --workers 2 Client, stdlib only, run with python3: ```python import http.client, threading, time TARGET = ('localhost', 8000) def fields(n=96, semis=8100): # 8100 semicolons keeps each field under gunicorn's limit_request_field_size # (8190) and each token under Django's MAX_HEADER_LENGTH (10000). Distinct # x{i} names stop order_by_precedence from deduplicating the tokens. return [f'text/x-poc{i}; x{i}="' + ';'*semis + '"' for i in range(n)] def burn(): c = http.client.HTTPConnection(*TARGET, timeout=120) c.putrequest('GET', '/items/', skip_accept_encoding=True) for f in fields(): c.putheader('Accept', f) c.endheaders() r = c.getresponse(); r.read(); c.close() return r.statu…

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON