Django REST framework: `TokenProxy.delete()` が成功と報告するにもかかわらず、認証トークンが取り消されない

### 概要

`TokenProxy` を使用して認証トークンを削除しようとすると、実際には何も削除されません。この操作は例外を発生させず、成功と報告されますが、データベースの行は残り、トークンは引き続き有効な状態になります。`.delete()` メソッドを `TokenProxy` インスタンスに対して呼び出すことで認証情報を取り消すアプリケーション、管理画面の統合、または運用スクリプトは、実際にはまだ有効なトークンを取り消したと誤認します。

### 詳細

`TokenProxy` は `pk` をオーバーライドして、ユーザー ID を返します (`rest_framework/authtoken/models.py:49-63`):

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

実際の `Token` モデルは、整数 ID ではなく、`key` をキーとして使用します。`Model.delete()` は、削除クエリを `pk` から構築するため、以下の SQL が実行されます。

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

これは、ユーザー ID を 40 文字のキー列と照合します。一致するものがないため、例外は発生せず、呼び出しは `(0, {'authtoken.TokenProxy': 0})` を返します。

DRF は、この同一性の不一致に対する修正ではなく、回避策を 2 つ搭載しています。`rest_framework/authtoken/admin.py` の `TokenAdmin.delete_model` は、実際の行を再取得します。

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

また、`TokenAdmin` は `actions = None` を設定し、コメントとして「マッピングされた ID とは互換性がない」と記載されています (`admin.py:50-53`)。したがって、出荷されている管理画面の機能は安全ですが、`TokenProxy.delete()` を呼び出す他のすべての箇所は安全ではありません。API には、この違いを示すものは何もありません。

`TokenProxy` は、`e2bd3b6a` (PR #7341, "Adjusted token admin to map to user ID") によって追加され、最初に 3.12.0 でリリースされました。

修正としては、`pk` のオーバーライドを削除するか、`TokenProxy` に実際の行に対して操作を行う `delete()` メソッドを提供するか、または、何も削除されなかった場合に例外を発生させる必要があります。

### 実証コード

`poc_tokenproxy_revocation.py` として保存し、実行してください。

```python
#!/usr/bin/env python
"""
django-rest-framework: TokenProxy.delete() は成功を報告するものの、何も取り消さない。

基準: トークンが存在する -> 200
トリガー: TokenProxy.objects.get(...).delete() -> 例外は発生せず、行は残存し、依然として 200
コントロール: Token.objects.get(key=...).delete() -> 行が削除され、401

コントロールがトリガーの意義を決定します。コントロールは、行が削除可能であり、認証チェックが有効であることを証明します。したがって、プロキシ削除後の 200 は、認証の取り消しが失敗したことを意味し、フレームワーク自体が壊れているわけではありません。

前提条件: django、djangorestframework がインポート可能。引数なし。
python poc_tokenproxy_revocation.py
終了ステータスが 0 の場合、認証の取り消し失敗が再現されたことを意味します。
"""
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--- 制御: 同じ行を具体的なモデルを通して削除する")
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(
"\n認証解除の失敗が再現されました:", ok,
"(baseline 200, プロキシ経由での削除後も200、実際の削除後401)",
)
return 0 if ok else 1

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

観察された出力:

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

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

--- TRIGGER: プロキシ経由で削除する
proxy.pk = 1 (TokenProxy.pk プロパティ)
SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('1')
raised = None
returned = (0, {'authtoken.TokenProxy': 0})
rows = 1
GET / with token -> 200

--- 制御: 同じ行を具体的なモデルを通して削除する
SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('6a644878...')
returned = (1, {'authtoken.Token': 1})
rows = 0
GET / with token -> 401

認証解除の失敗が再現されました: True
```

この制御部分がトリガーの有効性を示すものです。これは、行が削除可能であり、認証チェックが有効であることを証明します。したがって、プロキシ経由での削除後に200が返されるのは、認証解除の失敗であり、テスト環境の不具合ではありません。
これは、Django 6.1と5.2.17 LTSの両方で同じように再現されるため、最近のDjangoのバグではありません。

### 影響
```

認証情報の取り消しに失敗。`TokenProxy`インスタンスを通じてトークンを取り消そうとしたオペレーターが、エラーを受けずに処理が完了した場合、その認証情報は無期限に有効なままになります。これは、DRFの認証トークンが自動的に期限切れにならないためです。そのトークンを保持している人物は、退職した従業員、更新された連携、プロビジョニング解除されたと認識されているデバイス、またはインシデント対応中にアクセスが遮断されたと認識されていた攻撃者など、様々な人物が該当します。

このコードパスには、信頼できない入力は一切到達しません。そのため、この脆弱性は「AC:H」と評価されています。この脆弱性の悪用は、プロキシを通じてトークンを取り消すという動作に依存します。これは、`TokenProxy`が管理画面で登録されているモデルであり、多くのコードがそれを参照するため、自然な動作と言えます。3.12.0以降のバージョンで、DRF自身の`TokenAdmin.delete_model`を使用せずにトークンを取り消すような設定がある場合、その取り消し処理が正常に機能しているかどうかを監査する必要があります。

影響を受けるパッケージ:
- pip (Python) djangorestframework: 影響を受けるバージョンは3.12.0以上、3.18.2未満。3.18.2で修正済み。