GHSA-xw6w-gcfp-cf8m: SQL Injection

Published Oct 22, 2026
·
Updated

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 (restframework/authtoken/models.py:49-63):

python @property def pk(self): return self.userid

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 "authtokentoken" WHERE "authtokentoken"."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 restframework/authtoken/admin.py, TokenAdmin.deletemodel re-fetches the concrete row:

python def deletemodel(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 poctokenproxyrevocation.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 poctokenproxyrevocation.py Exit status 0 means the revocation failure reproduced. """ import sys

import django from django.conf import settings

settings.configure( DEBUG=False, SECRETKEY="poc", ALLOWEDHOSTS=[""], ROOTURLCONF=name, INSTALLEDAPPS=[ "django.contrib.contenttypes", "django.contrib.auth", "restframework", "restframework.authtoken", ], DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, DEFAULTAUTOFIELD="django.db.models.AutoField", USETZ=True, ) django.setup()

from django.contrib.auth import getusermodel # noqa: E402 from django.core.management import callcommand # 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 restframework.authentication import TokenAuthentication # noqa: E402 from restframework.authtoken.models import Token, TokenProxy # noqa: E402 from restframework.permissions import IsAuthenticated # noqa: E402 from restframework.response import Response # noqa: E402 from restframework.views import APIView # noqa: E402

class Whoami(APIView): authenticationclasses = [TokenAuthentication] permissionclasses = [IsAuthenticated]

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

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

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

def main(): callcommand("migrate", runsyncdb=True, verbosity=0) user = getusermodel().objects.createuser(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.capturedqueries: if "DELETE" in q["sql"]: print(" SQL:", q["sql"]) print("raised =", repr(raised)) print("returned =", returned) print("rows =", Token.objects.count()) afterproxy = probe(client, key) print("GET / with token ->", afterproxy)

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.capturedqueries: if "DELETE" in q["sql"]: print(" SQL:", q["sql"]) print("returned =", returned2) print("rows =", Token.objects.count()) afterreal = probe(client, key) print("GET / with token ->", afterreal)

ok = baseline == 200 and raised is None and afterproxy == 200 and afterreal == 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 "authtokentoken" WHERE "authtokentoken"."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 "authtokentoken" WHERE "authtokentoken"."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.deletemodel should audit whether its revocations took effect.

Affected Software

1 affected componentFixes available
pip/djangorestframework>=3.12.0<3.18.2
3.18.2

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/djangorestframework to a version that resolves this vulnerability.

    Fixed in 3.18.2
  2. Compensating control

    Revoke tokens through the concrete Token model using Token.objects.get(key=token_key).delete(), rather than calling delete() on a TokenProxy instance.

  3. Operational

    Audit revocations performed outside DRF's TokenAdmin.delete_model, including deployments on 3.12.0 or later, to confirm that the token row was deleted and the credential no longer authenticates.

Event History

Oct 22, 2026
Advisory Published
via GitHub·04:18 PM
Data Sourced
via GitHub·04:18 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which credential-revocation workflows are affected?

Applications, admin integrations, or operational scripts that call .delete() on a TokenProxy instance are affected. The built-in DRF admin deletion path re-fetches the concrete Token row before deleting it, so it uses a workaround for this mismatch.

2

What happens when the affected deletion path is used?

The call reports success without raising an exception, but deletes no database row. It returns (0, {'authtoken.TokenProxy': 0}), and the token remains usable for authentication.

3

What can be done if the affected code cannot be patched immediately?

Re-fetch the concrete Token object by its key and delete that object instead of deleting the TokenProxy. The documented workaround is Token.objects.get(key=obj.key).delete().

4

How can I confirm that a revocation attempt failed?

Check the delete result for a zero-row deletion and verify whether the token row still exists after the call. A token deleted through the affected TokenProxy path will remain in the authtoken_token table and continue to authenticate.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203