CVE-2026-114137

highpriority · track · 0EPSS 0.004 · 27 %ileUS
grimoire priorityP2 · Scheduled
Exploitable?
—
no known exploitation
Likely / severe?
0.35% (27th) EPSS
CVSS 7.4 · high
Is it mine?
1 products
Check your exposure
Who says so?
1 sources
osv

EPSS · scored 2026-10-23

Django REST framework is a toolkit for building Web APIs. From 3.12.0 until 3.18.2, TokenProxy.delete() in rest_framework/authtoken/models.py uses the proxy's user-id pk while the concrete Token token-string primary key identifies rows, so Django issues a deletion query that matches no row and reports zero deletions without raising an error. Applications, administrative integrations, or operational scripts that revoke credentials through a TokenProxy instance can therefore believe revocation succeeded while the token remains live and continues authenticating. DRF's own TokenAdmin.delete_model workaround is not affected, but other callers can leave access available indefinitely because DRF tokens do not expire automatically. This issue is fixed in version 3.18.2.

01

cvss

Technical severity, 0–10. One row per scoring source.

7.4
010

base score 0–10 · higher = more severe · hover a vector to decode⚠ sources disagree · medium–high · 6.8–7.4

v3.17.4highCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:Nsecondary · security-advisories@github.comexploitability 2.2 · impact 5.2
v3.17.4highCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:Nsecondary · OSV
v3.16.8mediumCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N/E:Usecondary · CVSS-BT (t0sche)
02

weaknesses

The CWE weakness classes this flaw belongs to.

03

affected products (1)

Products and version ranges known to be affected.

04

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-xw6w-gcfp-cf8mDjango REST framework: TokenProxy.delete() reports success and revokes nothing2026-10-22ghsa · osvUS

    ### 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`): ```python @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: ```sql 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: ```python 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. ```python #!/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.

05

sources

Which sources assert this record, and when.

published 2026-10-21modified 2026-10-23
06

references (3) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan