Starlette の Host ヘッダ検証欠如 CVE-2026-48710 - request.url.path を信じた認可がすり抜ける

Starlette の Host ヘッダ検証欠如 CVE-2026-48710 - request.url.path を信じた認可がすり抜ける

作成日:
読了:55
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

FastAPIでAPIを書いているなら、あなたのアプリはほぼ確実にStarletteの上で動いています。そのStarletteに、HTTPの Host ヘッダに区切り文字を1文字混ぜるだけで、ミドルウェアが見るパスと実際にルーティングされるパスをズラせる脆弱性がありました。CVE-2026-48710、発見者による通称は「BadHost」です。

2026年9月2日、CISAはこれを含む7件をKnown Exploited Vulnerabilities(KEV)カタログへ追加しました。実際に悪用が確認されているという意味です。しかもCVSS 3.1のスコアは6.5(MEDIUM)でしかありません。「MEDIUMだから後回し」という判断で棚卸しをすり抜けていた組織は少なくないはずです。

この記事では、CVEレコード、GitHub Security Advisory(GHSA-86qp-5c8j-p5mr)、修正コミットの差分、CISA KEVのJSONフィード、X41 D-SecとOSTIFの公開情報という確認可能な一次・準一次情報をもとに、何がどうズレるのか、自分のコードが該当するかをどう判定するか、何をすれば直るのかを整理します。攻撃の再現手順やスキャンツールの使い方は扱わず、防御側の判断材料に絞ります。

WARNING

この記事は2026年9月4日時点で確認できた情報にもとづいています。CVE-2026-48710 は悪用が確認済みで、CISA KEVの是正期限は 2026-09-16 です。修正版は Starlette 1.0.1 で、これは2026年5月21日にすでに公開されています。つまりパッチは3か月以上前から存在しており、今すぐ上げられる状況です。対応判断は必ずGitHub Security AdvisoryとKEVカタログで最新状態を確認したうえで行ってください。

タイムライン - パッチから3か月半後にKEV入り

X41 D-Secのアドバイザリ(X41-2026-002)に掲載された開示タイムライン、PyPIとGitHubのリリース日時、CVEレコードの日付、KEVのJSONフィードから、確認できる出来事だけを並べます。

日付出来事出典
2026-01-27X41 D-Sec による vLLM のソースコード監査中に発見X41 / secwest.net
2026-02-04Starlette 側へ通知、PoC提供X41 / secwest.net
2026-02-05ベンダー(メンテナ)が受領を確認X41 / secwest.net
2026-03-01修正パッチが提案されるX41 / secwest.net
2026-05-21 16:49 UTC修正コミット 764dab0 がマージ(PR #3279)GitHub
2026-05-21 21:57 UTCStarlette 1.0.1 リリース、PyPIへ公開GitHub / PyPI
2026-05-22公開開示、CVE-2026-48710 採番、badhost.org 公開X41 / secwest.net
2026-05-26 21:54 UTCCVEレコードが PUBLISHED に(CNAはGitHub)CVE.org
2026-05-26 22:16 UTCNVDに登録NVD
2026-06-04GHSA-86qp-5c8j-p5mr がレビュー済みとして公開OSV
2026-06-08連鎖先の CVE-2026-42271(LiteLLM)がKEV追加CISA KEV
2026-09-02CVE-2026-48710 がKEV追加、是正期限 2026-09-16CISA KEV
2026-09-03NVDのステータスが Analyzed にNVD

読み取れることが2つあります。

1つ目。修正から KEV 入りまで3か月半あります。 パッチが存在しないゼロデイだったPaperCut NG/MF の CVE-2026-81578 / CVE-2026-82078とは対照的に、こちらは「直せたのに直していない」期間が長かったケースです。OSTIFは開示記事で「slow uptake of updated versions of Starlette and discovery of more vulnerable live services has caused us serious concerns」と、アップデートの浸透の遅さを名指しで問題視していました。

2つ目。修正リリースは静かに出ています。 Starlette 1.0.1 のリリースノートは「Ignore malformed Host header when constructing request.url」の1行だけです。CVSSも6.5。依存関係の更新をリリースノートの語調で優先度づけしていると、これは間違いなく埋もれます。

何が起きるのか - 1文字でパスが入れ替わる

GHSAのSummaryはこう説明しています。

In affected versions, the HTTP Host request header was not validated before being used to reconstruct request.url. Because the routing algorithm relies on the raw HTTP path while request.url is rebuilt from the Host header, a malformed header could make request.url.path differ from the path that was actually requested.

要点は「ルーティングは生のパスを見る」「request.url は Host ヘッダから組み立て直す」という2つの経路が存在し、その2つが食い違いうることです。

GHSAが挙げている例をそのまま引きます。クライアントが送るのは次のリクエストです。

GET /foo HTTP/1.1
Host: example.com/abc?bar=

Starletteは影響を受けるバージョンで、URLを http://{host}{path} の形に連結してから再パースしていました。この場合、組み立てられる文字列は次のようになります。

http://example.com/abc?bar=/foo

これを urlsplit に通すと、パスは /abc、クエリは bar=/foo と解釈されます。実際にサーバーが受け取ってルーティングに使ったパスは /foo なのに、request.url.path/abc を返すという状態です。

ルーターは本物のパス /foo にディスパッチするので、エンドポイントの処理は普通に実行されます。一方、パスプレフィックスで認可をかけているミドルウェアは /abc を見て「保護対象ではない」と判断します。これがCWE-444(Inconsistent Interpretation of HTTP Requests、いわゆるHTTPリクエストスマグリング)に分類される理由です。同一のリクエストを、同一プロセス内の2つのレイヤーが違うものとして解釈しています。

必要なのは Host ヘッダに /?# のいずれかを1文字混ぜることだけです。secwest.netに掲載されたアドバイザリは、この点を「The exploit primitive is one character.」と表現しています。

脆弱性の構造 - ASGI の scope と URL 再構築の関係

なぜ2つの経路ができるのかは、ASGIの scope を見ると腑に落ちます。

ASGI サーバーがアプリに渡すもの

uvicornなどのASGIサーバーは、HTTPリクエストを受け取ると辞書(scope)に詰めてアプリケーションへ渡します。ASGI仕様のHTTP connection scopeで定義されているキーのうち、今回関係するのは次の4つです。

キー内容(ASGI仕様の定義)
pathクエリ文字列を除いたHTTPリクエストターゲット。パーセントエンコードとUTF-8バイト列をデコード済み
raw_path元のHTTPパス部分の生バイト列。提供できないサーバー実装もあるためオプション
serverサーバー側のホストとポートのタプル
headersヘッダ名と値のバイト列のイテラブル。受信順を保持

ここで重要なのは、scope["path"] はサーバーがリクエストラインから直接取り出した値で、Host ヘッダとは一切関係がないことです。Starletteのルーターはこの scope["path"] を使ってルート照合を行います。

request.url は再構築される

一方、request.urlscope からその場で組み立てられます。Starletteの Request.url プロパティは次のようになっています。

# starlette/requests.py(1.0.1)
@property
def url(self) -> URL:
    if not hasattr(self, "_url"):  # pragma: no branch
        self._url = URL(scope=self.scope)
    return self._url

URL(scope=...) の中身が問題の箇所でした。修正前は、headers から host を探して、見つかればそれをそのまま使っていました。

# starlette/datastructures.py(修正前の該当ロジック・要点のみ)
host_header = None
for key, value in scope["headers"]:
    if key == b"host":
        host_header = value.decode("latin-1")
        break
 
if host_header is not None:
    url = f"{scheme}://{host_header}{path}"   # ここで無検証に連結
elif server is None:
    url = path
else:
    ...  # scope["server"] から netloc を組み立てる

f"{scheme}://{host_header}{path}" という文字列連結のあとに再パースする設計が、そのまま境界のズレを許してしまいました。Host の値がRFC 9112 §3.2 の uri-host [ ":" port ]uri-host はRFC 3986 §3.2.2 の制限された host 文法)に従うことを、コード側は前提にしていたが検証はしていなかった、という構図です。

手元で挙動を確かめる

攻撃コードを書かなくても、URL クラスに scope を直接渡せばズレを再現できます。Starletteのテストコードに追加されたケースと同じ形です。

# starlette 1.0.1 未満の環境で実行すると、path が /health になる
from starlette.datastructures import URL
 
scope = {
    "scheme": "http",
    "server": ("example.com", 80),
    "path": "/admin",                                  # 実際に来たパス
    "query_string": b"",
    "headers": [(b"host", b"example.com/health?x=")],  # 区切り文字入りの Host
}
 
url = URL(scope=scope)
print(str(url))   # 1.0.1 未満: http://example.com/health?x=/admin
print(url.path)   # 1.0.1 未満: /health   / 1.0.1 以降: /admin
print(url.netloc) # 1.0.1 以降: example.com(scope["server"] へフォールバック)

1.0.1 以降なら Host が文法に合わないため無視され、scope["server"] の値から netloc が組み立てられます。結果として url.pathscope["path"] と一致します。

危ないコードの形

問題になるのは、この request.url.path を認可判断に使っているコードです。

# 危ないパターン: 再構築された URL を見て認可している
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import PlainTextResponse
 
 
class AdminGateMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # Host ヘッダ次第で /admin が /health に化ける
        if request.url.path.startswith("/admin"):
            if not await is_admin(request):
                return PlainTextResponse("Forbidden", status_code=403)
        return await call_next(request)

Host: example.com/health?x= を付けて /admin/users を叩くと、request.url.path/health になるので startswith("/admin") が偽になります。ミドルウェアは素通しし、ルーターは本物の /admin/users にディスパッチします。認可チェックだけが消える、という結果です。

同じ罠は、監査ログ、レートリミット、テナントスコープの判定など、「パスを見て何かを決める」あらゆる箇所にあります。secwest.netのアドバイザリは、影響として認証バイパスのほかにSSRFやRCEまで挙げていますが、これはStarlette自体がSSRFやRCEを起こすという意味ではありません。ゲートの向こう側にあるエンドポイントが何をするか次第で到達点が変わる、という「プリミティブ」としての評価です。

なお request.base_urlrequest.url_for() も同じ URL(scope=...) を経由します(base_urlscope を複製して path を差し替えたうえで URL を作る実装)。認可以外にも、リダイレクト先URLやメール本文中のリンク生成に base_url を使っているなら、そこも汚染経路になりえます。

CVSS 6.5 なのになぜ KEV に載ったのか

スコアと評価を並べると、この脆弱性の性格がよく分かります。

評価主体スコアベクタ / 補足
GitHub(CNA)CVSS 3.1: 6.5 MEDIUMAV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
NVD(Primary)CVSS 3.1: 6.5 MEDIUM同上のベクタで一致、ステータスは Analyzed
X41 D-Sec(発見者)CVSS 4.0: 7.0 HighAV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N
発見者コメント(secwest.net掲載)Critical「rating severely understates the severity of the bug downstream」
CISA SSVC(2026-09-02)Exploitation: active / Automatable: yes / Technical Impact: partialKEV追加時の判断

CVSS 3.1 の C:L/I:L は、ライブラリ単体で見れば「パス文字列が食い違うだけ」という評価です。X41のCVSS 4.0 ベクタが VC:N/VI:N/VA:N かつ SC:H/SI:H になっているのが示唆的で、脆弱なコンポーネント自身への影響はゼロ、後続システムへの影響が甚大という構造をきれいに表現しています。CVSS 3.1 にはこの「後続システム」の語彙がなく、S:U に丸められた結果が6.5です。

CVSSのバージョンによって深刻度の見え方が変わる問題は、Metabase の CVE-2026-72898でも触れました。今回はさらに極端で、スコアの担当範囲(ライブラリ層)と実際の被害の発生場所(アプリケーション層)が分離しているタイプです。

SSVC の Automatable: yes も重要です。ヘッダを1文字変えるだけなので、既存のインターネットスキャンツールにそのまま組み込めます。実際、CISA KEVのshortDescriptionはこう書いています(原文の chaned はKEVの記載どおりの誤記です)。

Kludex Starlette contains a HTTP request/response smuggling vulnerability that could allow attackers to inject paths into the host part, prepending the actual path leading to issues such as authentication bypass when the authentication depends on the reconstructed URL's path. This vulnerability could be chaned with CVE-2026-42271.

連鎖先の CVE-2026-42271

名指しされている CVE-2026-42271 は、BerriAI LiteLLM の認証済みコマンド実行です。CVEレコードによれば、MCPサーバーを保存前にプレビューするための POST /mcp-rest/test/connectionPOST /mcp-rest/test/tools/list が、stdioトランスポート用の command / args / env を含むサーバー設定をリクエストボディで受け取り、それをサブプロセスとして起動していました。影響範囲は 1.74.2 以上 1.83.7 未満、CVSS 4.0 で 8.7 です。

このエンドポイントは有効なプロキシAPIキーだけでゲートされており、ロールチェックがなかったとされています。つまり「認証さえ通れば低権限ユーザーでもコマンド実行できる」状態でした。ここにStarletteの認証バイパスが乗ると、認証すら不要になります。The Hacker Newsは、Horizon3.aiがこの連鎖を実証し、組み合わせたCVSSが10.0になると報じています。

CVE-2026-42271 自体は2026年6月8日にKEV追加済み(是正期限 2026-06-22)です。連鎖の片方が3か月前にKEV入りしていて、もう片方が今回追加されたという順序になります。MCPを介した攻撃面の広がりについてはAIエージェントとMCPのプロンプトインジェクションでも扱っています。

影響を受ける構成・受けない構成

「FastAPIを使っている」だけでは判定できません。切り分けの軸は2つあります。

軸1: Starlette のバージョン

範囲状態
1.0.1 未満のすべて影響あり(CVEレコード / OSVの範囲は導入バージョン0、修正1.0.1)
1.0.1 以降修正済み

発見者側(X41 / secwest.net)は影響範囲を「0.8.3 以上 1.0.1 未満」と表記しています。一方、CVEレコードの affected は < 1.0.1、OSVのECOSYSTEM rangeは introduced: 0 から fixed: 1.0.1 で、0.1.0 系まで列挙されています。下限の表記に食い違いがありますが、実務上は「1.0.1 未満はすべて影響あり」と扱えば安全側に倒れます。

軸2: 認可判断に何を使っているか

こちらが本質です。

影響を受けやすい構成:
  • ミドルウェアで request.url.path のプレフィックスを見て認可・拒否している
  • 監査ログやレートリミットのキーに request.url.path を使っている
  • ASGIサーバー(uvicorn / hypercorn / daphne / granian)をリバースプロキシなしで直接インターネットや内部ネットワークに露出している
  • LiteLLM、vLLM などのLLMゲートウェイ・推論サーバーを直接HTTPで公開している
  • HTTP/3 や QUIC を終端するフロントエンドを使っていて、そのHostヘッダ検証挙動を確認していない
比較的影響を受けにくい構成:
  • 認可をFastAPIの Depends() / Security() でルート単位に付けている(ルート照合は scope["path"] ベース)
  • 認可がパスではなくリソースIDやロールに基づいている
  • nginx、Apache httpd、Cloudflare などがHostヘッダを検証・正規化してから転送している

NOTE

リバースプロキシによる緩和には条件が付きます。OSTIFとsecwest.netは「nginx, Apache httpd, and Cloudflare reject the PoC by default」としつつ、自分の構成で必ず検証することを求めています。加えてGHSAは、アプリ側が X-Forwarded-Host のような攻撃者制御ヘッダを別の箇所で信頼していれば緩和にならない、と釘を刺しています。プロキシ経由でも「アップグレードしなくてよい」にはなりません。

誰が影響を受けているのか

badhost.org と OSTIF が挙げている影響下のプロジェクト群は、そのまま現在のPython製AI基盤の一覧になっています。FastAPI、LiteLLM、vLLM、Text Generation Inference、各種OpenAI互換シムプロキシ、MCPサーバー、エージェントハーネス、評価ダッシュボード、モデル管理UI。

ディストリビュータ側の対応もCVEレコードから確認できます。Red HatはCVE-2026-48710のCNAコンテナに、Red Hat AI Inference Server 3.3(vllm-cuda-rhel9 / vllm-rocm-rhel9)、Ansible Automation Platform 2.6 / 2.7 の lightspeed-chatbot-rhel9 などを影響製品として登録しています。自分では pip install starlette した覚えがなくても、AI関連のコンテナイメージの中に入っているというのが実態です。

自分のコードが危ないかを確認する

1. 認可判断で request.url を読んでいないか探す

まずコード側から見るのが確実です。バージョンが上がっていても、同じバグクラスは将来また出ます。

# 認可・監査・ルーティング判断で request.url / request.url.path を読んでいる箇所
grep -rn --include='*.py' -E 'request\.url(\.path)?\b' .
 
# ミドルウェアやデコレータの定義に絞りたい場合
grep -rn --include='*.py' -E 'BaseHTTPMiddleware|async def dispatch|def __call__\(self, scope' .
 
# 逆に、安全側の書き方をどれだけ使えているかの確認
grep -rn --include='*.py' -E 'scope\["path"\]|request\.scope\[.path.\]' .

request.url.path が出てきたら、その値がセキュリティ上の判断に使われているか、単なる表示・ログ整形なのかを1件ずつ見ます。判断に使っていれば要修正です。

X41は静的解析用のSemgrepルールとCodeQLクエリを github.com/x41sec/poc/tree/master/starlette-host-header で公開しています。規模の大きいコードベースで機械的に洗い出したい場合の選択肢になります。

2. インストールされているバージョンを確認する

# 単体の確認
pip show starlette
 
# fastapi と併せて
pip list | grep -iE '^(starlette|fastapi) '
 
# 実際にインポートされるバージョン(複数環境の取り違え防止に有効)
python -c "import starlette; print(starlette.__version__)"

uv を使っている場合は次のとおりです。

uv pip list | grep -i starlette
 
# 依存ツリーのどこから引かれているかまで確認する
uv tree --package starlette
 
# ロックファイルを更新して 1.0.1 以上へ引き上げる
uv lock --upgrade-package starlette
uv sync

PyPI上でどのバージョンが存在するかを見たい場合は、experimental扱いですが pip index が使えます。

# 注意: pip index は experimental コマンド
pip index versions starlette

3. コンテナイメージの中まで見る

OSTIFが明示的に警告しているのがここです。

Rebuild and redeploy every container, virtualenv, and bundled artifact that pins or vendors Starlette. Bundled installs are common in LLM tooling; pip list on the host is not enough. Audit images.

ホスト上の pip list はイメージの中身を教えてくれません。

# イメージ内で実際に使われる starlette のバージョンを見る
docker run --rm --entrypoint python your-image:tag \
  -c "import starlette; print(starlette.__version__)"
 
# 依存の棚卸しをまとめてやるなら
pip install pip-audit
pip-audit --requirement requirements.txt

4. 外部からの到達性を確認する

内部向けサービスだから、という理由で優先度を落とす前に、実際にどこから届くのかを確認します。ワークステーション、VPNサブネット、検証用ネットワークからも「内部」として到達できる構成は珍しくありません。

# 別ネットワークから、ASGI サーバーが直接待ち受けているポートへの到達性を確認
for p in 8000 8080 4000 11434; do
  timeout 3 bash -c "echo > /dev/tcp/api.example.com/$p" \
    && echo "OPEN  $p" || echo "closed $p"
done

修正版と対処

1.0.1 で何が変わったか

修正コミット 764dab0dcfb9033d75442d7a359645c9f94648c6(PR #3279、コミット日時 2026-05-21 16:49 UTC)が触ったのは starlette/datastructures.py とそのテストだけです。追加されたのは正規表現1本と、その適用条件です。

# starlette/datastructures.py(1.0.1)
# Rejects Host header chars (/, ?, #, @, ...) that would let urlsplit
# produce a path differing from scope["path"].
_HOST_RE = re.compile(
    r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9.:]+\])(?::[0-9]+)?$",
    re.IGNORECASE,
)

そして URL.__init__ の条件がこう変わりました。

# 修正前
if host_header is not None:
    url = f"{scheme}://{host_header}{path}"
 
# 修正後
if host_header is not None and _HOST_RE.fullmatch(host_header):
    url = f"{scheme}://{host_header}{path}"

正規表現が許すのは、英数字・ドット・ハイフンからなるホスト名か、角括弧で囲まれたIPv6アドレス、そして任意のポート番号だけです。/?#@、バックスラッシュ、空白はすべて弾かれます。弾かれた場合は Host ヘッダを無視して scope["server"] にフォールバックする、という設計です。

追加されたテストは、foo/?x=foo/#foo/baruser@foofoo\barfoo bar の6パターンで url.path/admin のまま、url.netlocexample.com になることを確認しています。

アップグレード手順

# pip
pip install --upgrade "starlette>=1.0.1"
 
# requirements.txt を使っているなら下限を明示する
# starlette>=1.0.1
 
# Poetry
poetry add "starlette@^1.0.1"
poetry lock && poetry install
 
# uv
uv lock --upgrade-package starlette
uv sync

執筆時点でのPyPI上の最新版は 1.6.0 です。1.0.1 以降の系列であれば修正は含まれます。

FastAPI の依存範囲に注意

ここが実務上いちばん引っかかりやすいところです。FastAPIのリポジトリの pyproject.toml を確認すると、依存宣言は次のようになっています。

dependencies = [
    "starlette>=0.46.0",
    "pydantic>=2.9.0",
    ...
]

PyPI上の最新リリース(0.141.1)のメタデータも starlette>=0.46.0 です。下限は 0.46.0 のままで、1.0.1 以上を要求してはいません。上限も設定されていません。

これが意味するのは次の2点です。

  • FastAPIを最新に上げただけでは、Starletteが修正版になる保証はない。既存のロックファイルやイメージに古いStarletteが固定されていれば、そのまま残ります
  • 逆に、上限がないのでStarletteだけを 1.0.1 以上に上げてもFastAPIの依存解決は壊れない

つまり対処としては、FastAPIのバージョンとは独立に、Starletteの下限を自分のプロジェクト側で明示的に固定するのが確実です。

なお、FastAPI側がこのCVEに対して独自のアドバイザリや依存下限の引き上げを行ったかどうかは、今回確認できませんでした(未確認)。判断は上記の依存範囲の事実にもとづいて行ってください。

恒久対策: scope["path"] を読む

バージョンを上げるのは必須ですが、それだけだと同じバグクラスが再発したときにまた刺さります。OSTIFとsecwest.netがそろって「durable fix」と呼んでいるのは、再構築されていない値を読むことです。

# 修正版: 再構築を経ていない scope の値で判断する
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import PlainTextResponse
 
 
class AdminGateMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # scope["path"] は ASGI サーバーがリクエストラインから取り出した値。
        # Host ヘッダの影響を受けない。
        path = request.scope["path"]
        if path.startswith("/admin"):
            if not await is_admin(request):
                return PlainTextResponse("Forbidden", status_code=403)
        return await call_next(request)

素のASGIミドルウェアとして書くなら、そもそも Request を作らずに済みます。

class AdminGateASGIMiddleware:
    def __init__(self, app):
        self.app = app
 
    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
 
        path = scope["path"]
        if path.startswith("/admin") and not await is_admin_from_scope(scope):
            response = PlainTextResponse("Forbidden", status_code=403)
            await response(scope, receive, send)
            return
 
        await self.app(scope, receive, send)

切り替える際に確認しておきたい点が2つあります。

  1. scope["path"] はASGI仕様上パーセントデコード済みです。%2e%2e のようなエンコードを自前で正規化していた処理があれば、前提が変わっていないか確認します。生バイト列が必要なら scope.get("raw_path") がありますが、これはオプションで、提供しないサーバー実装もあります
  2. Mount の内側では、子アプリのscopeで pathroot_path が書き換わります。アプリ全体の先頭に置いたミドルウェアと、マウント配下に置いたミドルウェアで見える値が違うので、自分の構成で実際に何が入るかをログに出して確認してください

暫定緩和1: TrustedHostMiddleware

すぐにデプロイできない場合の足止めとして、TrustedHostMiddleware が使えます。Starletteのソースを読むと、判定はこうなっています。

# starlette/middleware/trustedhost.py(1.0.1)
headers = Headers(scope=scope)
host = headers.get("host", "").split(":")[0]
is_valid_host = False
for pattern in self.allowed_hosts:
    if host == pattern or (pattern.startswith("*") and host.endswith(pattern[1:])):
        is_valid_host = True
        break

Host: example.com/health?x= の場合、split(":")[0] の結果は example.com/health?x= になり、許可リストのどのパターンとも一致しません。結果として 400 Invalid host header が返ります。

from fastapi import FastAPI
from starlette.middleware.trustedhost import TrustedHostMiddleware
 
app = FastAPI()
 
# 重要: allowed_hosts に "*" を含めると allow_any になり素通しになる
app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=["api.example.com", "*.internal.example.com"],
)

使ううえでの条件は3つです。

  • allowed_hosts* を含めてはいけないallow_any になり、判定そのものがスキップされます
  • ミドルウェアスタックの最も外側に置く。認可ミドルウェアより後段だと意味がありません
  • これは緩和であってパッチではありません。request.url.path を読むコードは残ったままです

暫定緩和2: リバースプロキシでの Host 検証

ASGIサーバーを直接公開している構成なら、前段にHTTP/1.1準拠のリバースプロキシを置くのが定石です。nginxなら、既定のサーバーブロックで想定外のHostを落とす形にします。

# 想定していない Host のリクエストを 444 で切る
server {
    listen 80 default_server;
    server_name _;
    return 444;
}
 
server {
    listen 80;
    server_name api.example.com;
 
    location / {
        proxy_pass http://127.0.0.1:8000;
        # 上流へは検証済みの値を渡す
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

ここでも注意点があります。proxy_set_header Host $host;$host はクライアントのHostヘッダ由来の値です。確実に固定したいなら proxy_set_header Host api.example.com; のようにリテラルを渡すほうが安全です。また、アプリ側が X-Forwarded-Host を信頼して何かを組み立てているなら、そちらの経路も同時に塞ぐ必要があります。ヘッダを信頼する設計の落とし穴という点では、CORSの仕組みとハマりどころで扱ったOriginヘッダの扱いと同じ構図です。

HTTP/3やQUICを終端するフロントエンドを使っている場合、Hostヘッダ(HTTP/3では :authority 疑似ヘッダ)の検証挙動は実装依存です。secwest.netのアドバイザリは、この構成ではプロキシを緩和として当てにする前に実際にテストせよと明記しています。

同日 KEV 追加の7件 - 狙われているのは開発とAIの基盤

2026年9月2日、CISAは7件を一度にKEVへ追加しました(カタログバージョン 2026.09.02、収録1694件、リリース時刻 16:54 UTC)。KEVのJSONフィードから、7件の内容を並べます。

CVE製品種別(KEVのCWE)是正期限法的トリアージ要件
CVE-2026-9586Sangoma SwitchvoxSQLインジェクション(CWE-89)2026-09-05Yes
CVE-2026-48710Kludex StarletteHTTPリクエストスマグリング(CWE-444)2026-09-16No
CVE-2026-49869Kestra OSSOSコマンドインジェクション(CWE-78 ほか)2026-09-05Yes
CVE-2026-59822BerriAI LiteLLM不適切な認証(CWE-287 / CWE-306)2026-09-16No
CVE-2026-82329JFrog Artifactory不適切な認証(CWE-287)2026-09-05Yes
CVE-2026-83548SonicWall SMA1000SSRF(CWE-918 / CWE-441)2026-09-05Yes
CVE-2026-83549SonicWall SMA1000OSコマンドインジェクション(CWE-78)2026-09-05Yes

7件のうち4件(Starlette、Kestra、LiteLLM、JFrog Artifactory)が開発・データ・AI基盤のソフトウェアです。残る3件はネットワーク境界機器(SonicWall SMA1000 が2件)とIP-PBX(Sangoma Switchvox)という、従来型のKEVらしい顔ぶれです。

内訳を並べると傾向が見えます。

  • Kestra OSS(CVE-2026-49869): ワークフローオーケストレータ。KEVのshortDescriptionは「could allow an unauthenticated remote attacker to create and execute arbitrary workflows without credentials」。ワークフロー実行基盤で未認証の任意ワークフロー作成・実行が通る、というのは事実上のRCEです
  • BerriAI LiteLLM(CVE-2026-59822): AIゲートウェイ。「improper authentication vulnerability in the MCP Streamable HTTP endpoint that could allow an unauthenticated attacker to establish an authenticated MCP session using an arbitrary Bearer token」。任意のBearerトークンで認証済みMCPセッションを張れる、という内容です。LiteLLMはこれで3か月のあいだに2件目のKEV入り(6月に CVE-2026-42271)になります
  • JFrog Artifactory(CVE-2026-82329): アーティファクトリポジトリ。「under default configuration can allow an unauthenticated attacker with network access to obtain administrative privileges」。既定構成で未認証から管理者権限、という最悪のパターンで、ビルド成果物の配布経路そのものが乗っ取られます

つまりこの日のKEVは、「境界機器」と「開発・AI基盤」が半々という構成でした。ソフトウェアサプライチェーンと推論基盤が、VPN装置と同じ扱いで攻撃対象リストに載っている、という事実です。同様の傾向はLangflow の未認証RCEのときにも見えていました。

是正期限の差が意味するもの

見落としやすいのが期限の差です。Starlette と LiteLLM は14日(2026-09-16)、残る5件は3日(2026-09-05)

KEVのJSONを見ると、期限14日の2件は forensicTriage: No、期限3日の5件は forensicTriage: Yes になっています。BOD 26-04 の要求は「publicly exposed assets that grant total control of the asset post-exploitation」の迅速な修正を優先する、という書き方なので、単体で資産の完全掌握に至るものは3日、プリミティブとして機能するものは14日という区別がなされたと読むのが自然です(CISAは個別の理由を公表していないため、この解釈は推測です)。

期限が長いことは危険度が低いことを意味しません。Starletteの場合、組織内の何個のサービスにどのバージョンが入っているかを数えるところから始まるため、単一製品のパッチ適用より作業量が多くなりがちです。14日はむしろ、棚卸しの手間を織り込んだ数字と考えるべきです。

検知とログ調査の観点

1. Host ヘッダに区切り文字が入っているリクエストを探す

secwest.netのアドバイザリが挙げている、コストの安い遡及調査はこれです。

Any access log entry where the Host header contains any of: /, ?, #, \, @. Legitimate clients do not emit these characters in Host.

正規のクライアントがこれらの文字をHostに入れることはありません。1件でも出たら、少なくともスキャンは受けています。

# nginx のアクセスログで Host を記録している場合の例
grep -E '"[^"]*[/?#\\@][^"]*"' /var/log/nginx/access.log | head -50

ここで重要な前提があります。Hostヘッダを記録していないログ形式では、この調査はできません。 nginxの既定の combined フォーマットにはHostが含まれません。記録するには明示的な設定が必要です。

log_format hostaware '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status $body_bytes_sent '
                     '"$http_referer" "$http_user_agent" '
                     'host="$http_host" server_name="$server_name"';
 
access_log /var/log/nginx/access.log hostaware;

uvicornの既定のアクセスログも同様です。ソースを確認すると、出力しているのは次の要素だけです。

# uvicorn/config.py の既定フォーマット
'%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'

つまりクライアントアドレス、メソッド、パス(scope由来なので本物のパス)、HTTPバージョン、ステータスコードのみで、Hostヘッダは出ません。裏を返せば、uvicornの既定アクセスログには「本物のパス」が記録されているということでもあります。これは次の観点につながります。

2. ディスパッチ先とアプリが記録したパスの不一致を探す

もっとも直接的なシグネチャは、同じリクエストに対してルーターが実行したハンドラと、アプリが記録した request.url.path が食い違うことです。

# 調査用: 両者を突き合わせて記録するミドルウェア(アップグレードまでのつなぎ)
import logging
 
logger = logging.getLogger("badhost.audit")
 
 
class HostMismatchAuditMiddleware:
    def __init__(self, app):
        self.app = app
 
    async def __call__(self, scope, receive, send):
        if scope["type"] == "http":
            headers = dict(scope.get("headers", []))
            host = headers.get(b"host", b"").decode("latin-1")
            real_path = scope["path"]
 
            from starlette.datastructures import URL
 
            reconstructed = URL(scope=scope).path
 
            if reconstructed != real_path:
                logger.warning(
                    "host header mismatch: host=%r scope_path=%r url_path=%r",
                    host,
                    real_path,
                    reconstructed,
                )
        await self.app(scope, receive, send)

1.0.1 以降ではこの2つが一致するため、このミドルウェアは何も出しません。アップグレード前の環境で、露出期間中に何が来ていたかを可視化するための道具として使います。

3. ゲートの向こう側のエンドポイントの利用状況を洗う

Starlette自体は情報を漏らしません。被害があるとすれば、すり抜けた先のエンドポイントが何をしたかです。

  • 管理系エンドポイント(/admin/internal/metrics/shutdown など)への想定外のアクセス
  • LLMゲートウェイなら、APIキーの発行・一覧・削除、モデル追加、MCPサーバー登録の履歴
  • モデルサービングなら、モデルのロード・アンロード、外部URLからのモデル取得
  • ファイルアップロード、プラグイン読み込み、コード評価系の機能の実行履歴

該当するアクセスがあれば、認証情報のローテーション(APIキー、プロキシキー、サービスアカウント、モデルレジストリの資格情報)まで含めて計画します。HTTPステータスコードだけを見ていると「200が返っている=正常」と読んでしまうので、誰が何を実行したかの監査ログ側を見る必要があります。

4. 調査の限界を明示する

Hostヘッダを記録していなかった期間については、「痕跡がない」ではなく「確認できない」としか書けません。ログの保持期間、記録項目、外部転送の有無を先に確認したうえで、報告書の書き方を決めてください。

教訓 - 「同じものを2回パースしない」

今回の件から一般化できることを3つ挙げます。

1つ目は、同じ情報を2つの経路で解釈しない、という設計原則です。 ルーターは scope["path"]、ミドルウェアは request.url.path。この2つが同じ値を返すという暗黙の前提が崩れたのが今回の本質でした。CWE-444(Inconsistent Interpretation of HTTP Requests)は本来、フロントエンドとバックエンドの2台のサーバー間で起きる問題として語られますが、同一プロセス内の2つのレイヤーでも同じことが起きます。文字列に組み立て直して再パースする処理を見かけたら、そこは疑ってよい箇所です。

2つ目は、CVSSのスコアが「誰への影響を測っているか」を確認する習慣です。 6.5 MEDIUM はライブラリ層の評価としては妥当ですらあります。問題は、ライブラリの利用者にとっての深刻度がそこに現れないことです。X41がCVSS 4.0 で VC:N/VI:N/VA:N/SC:H/SI:H と付けたのは、まさにこの分離を表現するためでした。依存ライブラリのCVEは、スコアではなく「自分のコードがその機能をどう使っているか」で優先度を決めるべきです。

3つ目は、依存の下限を自分で決める運用です。 FastAPIの依存宣言が starlette>=0.46.0 のままである以上、上位フレームワークの更新に任せていても修正版は入りません。セキュリティ修正が入った依存については、自分のプロジェクトのマニフェストに下限を明示的に書くのが確実です。あわせて、コンテナイメージの中身を定期的に監査する仕組み(SBOM生成、pip-audit、イメージスキャン)がないと、ホスト上の pip list だけでは実態を掴めません。

まとめ

  • CVE-2026-48710(BadHost)は Starlette の Host ヘッダ検証欠如。Host/?# を1文字混ぜると request.url.pathscope["path"] とズレ、パスベースの認可がすり抜ける
  • ルーターは scope["path"] でディスパッチするのでエンドポイントは実行される。消えるのはミドルウェアの認可判断だけ、というのが最悪の組み合わせ
  • 修正版はStarlette 1.0.1(2026-05-21リリース)。1.0.1 未満はすべて影響あり。執筆時点のPyPI最新は 1.6.0
  • CVSS 3.1 は 6.5 MEDIUM だが、発見者(X41 D-Sec)は CVSS 4.0 で 7.0 High、実質は Critical と評価。スコアはライブラリ層の影響しか測っていない
  • 2026年9月2日にCISA KEV追加、是正期限 2026-09-16。SSVCは Exploitation: active / Automatable: yes
  • KEVは連鎖先として CVE-2026-42271(LiteLLM の認証済みコマンド実行)を名指ししている。組み合わせると未認証RCEになる
  • FastAPIの依存は starlette>=0.46.0 のまま。FastAPIを上げるだけでは修正版にならないので、自分のプロジェクトで下限を明示する
  • 恒久対策は、認可・監査・レートリミットの判断を request.url.path から request.scope["path"] へ切り替えること
  • 暫定緩和は TrustedHostMiddlewareallowed_hosts* を入れない、最外側に置く)と、リバースプロキシでのHost検証。どちらもアップグレードの代わりにはならない
  • ログ調査は「Hostヘッダに / ? # \ @ が含まれるリクエスト」と「ディスパッチ先と記録されたパスの不一致」が入口。ただしHostを記録していないログでは調査自体ができない
  • 同日KEV追加の7件のうち4件が開発・AI基盤(Starlette / Kestra / LiteLLM / JFrog Artifactory)。ビルド基盤と推論基盤が境界機器と同列に狙われている

Pythonの依存ツリーの深いところにいるライブラリのMEDIUMなCVEは、いちばん後回しにされやすい種類のものです。今回はそれが3か月半後にKEV入りしました。国内のインシデント動向は2026年 日本のセキュリティインシデントまとめでも追っています。まずは pip show starlette を1回叩くところから始めてください。

参考資料

PaperCut NG/MF のゼロデイ CVE-2026-81578 / CVE-2026-82078 - 印刷サーバーが未認証RCEの入口になった

PaperCut NG/MF のゼロデイ CVE-2026-81578 / CVE-2026-82078 - 印刷サーバーが未認証RCEの入口になった

41

2026年8月27日にPaperCutが緊急公表したNG/MFのゼロデイ2件が、8月31日にCISA KEVへ追加されました。認証チェック完了前にバックエンド処理が走るCVE-2026-81578と、DBドライバ名を検証せずクラスロードするCVE-2026-82078。単体では未認証RCEにならない2つが連鎖する構造、CVSSが8.8と9.4に割れる理由、緊急パッチRelease 3のビルド番号、公式が公開したIoCとログ調査手順、そして2023年のPaperCut事件との違いを一次情報から整理します。

Langflow の未認証リモートコード実行 CVE-2026-9198 - 2つの「便利な機能」が連鎖してCVSS 9.8になった話

Langflow の未認証リモートコード実行 CVE-2026-9198 - 2つの「便利な機能」が連鎖してCVSS 9.8になった話

29

AIワークフロー基盤LangflowのCVE-2026-9198(CVSS 9.8)を一次情報で整理します。auto_loginが配るsuperuserトークンとvalidate/codeのexec()実行が連鎖し、既定構成のまま未認証RCEが成立。2026年8月4日にCISA KEV追加、是正期限は8月7日。対象バージョン1.0.0から1.10.0、修正版1.10.1、侵害確認手順までまとめます。

乱数の仕組み - 疑似乱数(PRNG)と暗号論的乱数(CSPRNG)を実装して確かめる

乱数の仕組み - 疑似乱数(PRNG)と暗号論的乱数(CSPRNG)を実装して確かめる

73

コンピュータが乱数を作る仕組みを、動くコードと実測値で解説します。線形合同法の下位ビットが周期2で交互に並ぶこと、RANDUの3点組が15枚の平面にしか乗らないこと、Mersenne Twisterの624語の出力から内部状態を復元して以降10万個の出力を完全に予測できることを実際に走らせて確かめます。V8のソースからMath.random()がxorshift128+で2の53乗通りの値を返すことを読み、ECMAScript仕様が品質を規定していないことを確認し、getrandom(2)とLinuxカーネルのChaCha20ベースCSPRNG、RDRANDとRDSEEDの違いを一次ソースで整理。sortによるシャッフルの71%の偏り、モジュロバイアス、棄却法での直し方まで実測しました。