CyberFix Note
脆弱性・CVE解説

StarletteのHostヘッダ検証不備CVE-2026-48710。request.url.pathとルーティングのずれが認可を迂回する

対象の目安: PythonのWebアプリを運用する開発者と運用担当 / 実務

ソウ攻撃・脆弱性リサーチ担当
・ 約39分で読めます
StarletteのHostヘッダ検証不備CVE-2026-48710。request.url.pathとルーティングのずれが認可を迂回する

HTTPのHostヘッダにスラッシュを1文字入れるだけで、認可ミドルウェアが見るパスと、ルータが実際に呼び出すパスがずれます。これがPython製ASGIフレームワークのStarletteに見つかったCVE-2026-48710、通称BadHostの正体です。ミドルウェアは /abc へのリクエストだと判断して認可を通し、ルータは /foo のエンドポイントを呼び出します。同じプロセスの中で、パスの解釈が二つに割れます。

CISAは2026年9月2日、この脆弱性をKnown Exploited Vulnerabilities(KEV)カタログへ追加しました。修正版のStarlette 1.0.1が公開されたのは2026年5月21日ですから、修正から3か月半を経て、実際の悪用が確認された脆弱性としてカタログに載ったことになります。同じ日にはLiteLLMのCVE-2026-59822も追加されており、CISAはKEVの記載でCVE-2026-48710がLiteLLMのCVE-2026-42271と連鎖しうると注記しています。CVE-2026-42271自体のKEV追加はこれより早く、2026年6月8日です。

この記事は、FastAPIを含むStarlette派生のアプリを運用する開発者と運用担当に向けて、GitHub Security Advisory、NVD、CISAのKEVカタログ、Starletteのリリースと修正コミット、報告者であるX41 D-Secのアドバイザリという一次情報をもとに、機構と対応を整理します。攻撃を再現する手順や動作するコードは扱いません。動作の確認を行う場合は、自分が管理する、または許可を得た環境に限ってください。記述は執筆時点(2026年9月3日)に確認できた範囲に限ります。

KEVカタログ収録が日本の組織にとって持つ意味

CISAのKEVカタログは、実際の悪用が確認された脆弱性を列挙した一覧です。執筆時点で参照できる版はcatalogVersion 2026.09.02、リリース日時は2026年9月2日で、収録件数は1694件です。この日に追加されたのは7件で、そのうちの1件がCVE-2026-48710でした。

カタログの登録内容は次のとおりです。ベンダー名はKludex、製品名はStarletteと記載されています。

項目
cveIDCVE-2026-48710
vendorProject / productKludex / Starlette
vulnerabilityNameKludex Starlette HTTP Request/Response Smuggling Vulnerability
dateAdded2026-09-02
dueDate2026-09-16
cwesCWE-444
knownRansomwareCampaignUseUnknown
forensicTriageNo

このdueDateを理解するには、CISAのBOD 26-04を見る必要があります。2026年6月10日に発出されたこの拘束的運用指令は、Federal Civilian Executive Branch(米国の連邦文民行政機関)とその情報システムを対象としており、44 U.S.C. 3553の規定により国家安全保障システムや国防関連、情報コミュニティの一部システムは除外されます。是正期限は資産の公開状況、KEV掲載の有無、悪用の自動化可能性、技術的影響という4つの変数で決まり、公開資産で自動化された悪用があり資産を完全に掌握される場合の3日から、公開されていない資産の60日以上まで幅があります。

つまり、2026年9月16日という日付は米国の連邦機関に対する行政上の期限であり、日本の組織を法的に拘束するものではありません。日本の組織にとっての価値は別のところにあります。KEVに載ったということは、CISAが実際の悪用を確認したと判断したという情報です。CVSSの数値や理論上の攻撃難易度とは独立した、現実の観測にもとづく優先度の根拠として使えます。

なお、このエントリのforensicTriageはNoです。BOD 26-04では一部のKEVエントリに侵害前提の調査要件が付きますが、CVE-2026-48710にはそれが付いていません。同じ日に追加されたKestraやJFrog Artifactory、SonicWall SMA1000の各エントリはYesとなっており、CISAが個別に判断していることが読み取れます。

KEVとEPSSを日々の優先度付けにどう組み込むかは、次の記事で整理しています。

あわせて読みたい

脆弱性対応の優先順位付け。CVSSだけに頼らないEPSSとCISA KEVの使い方

Hostヘッダの1文字がrequest.url.pathをずらす機構

HTTPのHostヘッダの文法は、RFC 9110の7.2で Host = uri-host [ ":" port ] と定義されています。ここでのuri-hostは、RFC 3986の3.2.2が定めるhostの文法に従います。この文法にスラッシュや疑問符やシャープは含まれません。つまり example.com/abc?bar= というHost値は、そもそもHTTPの規格に照らして不正な値です。

Starlette 1.0.0以前の URL クラスは、ASGIのscopeからURLを組み立てるときにこの検証を行っていませんでした。該当箇所は次のような形です。

# Starlette 1.0.0 の 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:
    host, port = server
    ...

Hostヘッダが存在すれば、その値をそのまま文字列連結してURLを作り、あとから urlsplit で再パースします。ここに規格外の文字が入ると、再パースの段階でパスとクエリとフラグメントの境界が移動します。

GitHub Security Advisoryが挙げている例で追いかけます。クライアントが次のリクエストを送ったとします。

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

このとき再構築される文字列は http://example.com/abc?bar=/foo です。これをURLとして解釈すると、ホストは example.com、パスは /abc、クエリは bar=/foo になります。実際にサーバが受け取ったパスは /foo なのに、request.url.path/abc を返すという食い違いが生まれます。

そして、ルーティングはこの再構築されたURLを見ていません。StarletteのルータはASGIのscopeに入っている path を使うため、/foo のエンドポイントが正しく呼び出されます。エンドポイントは動く、しかし手前のミドルウェアは /abc を見ている、という状態です。

この二重解釈がASGIのscopeとどう対応するかを整理すると次のようになります。ASGIの仕様では、HTTPのconnection scopeに含まれるキーのうち path は「クエリ文字列を除いたHTTPリクエストターゲットで、パーセントエンコード列とUTF-8バイト列を文字へデコードしたもの」と定義され、raw_path は「クエリ文字列を除いた元のHTTPパス構成要素で、Webサーバが受け取ったバイト列そのまま」と定義されています。raw_path は任意のキーで、無い場合はNoneです。

参照する値由来攻撃者のHostヘッダで変わるか
scope["path"]受信したリクエストターゲットをデコードした値変わらない
scope["raw_path"]受信したバイト列そのまま(任意キー)変わらない
request.url.pathHostヘッダとパスの連結を再パースした値1.0.0以前では変わる

CVEレコードでCNAであるGitHubが登録しているCWEはCWE-444で、CISAのKEVカタログにも同じCWE-444が掲載されています。MITREの正式名称はInconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')です。MITREの定義は、プロキシやファイアウォールのような中間HTTPエージェントが、最終的な宛先での処理と異なる解釈をしてしまう弱点を指しています。今回のずれは単一プロセスの内部で起きているため定義の文面とは距離がありますが、二つの解釈が食い違って認可を素通りさせるという構図は共通します。NVDにはRed Hatが付与したCWE-1289(Improper Validation of Unsafe Equivalence in Input)も併記されており、こちらは入力の検証漏れという側面を捉えた分類です。

リクエストの解釈が食い違うことで防御が抜ける類型そのものは、次の記事で扱っています。

あわせて読みたい

HTTPリクエストスマグリングの仕組みと対策。フロントとバックの境界解釈のズレを塞ぐ

GitHub Security AdvisoryのGHSA-86qp-5c8j-p5mrは、影響を受けるバージョンをStarlette 1.0.0以前、最初の修正版を1.0.1としています。CVSS v3.1のベクトルはCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:Nでスコアは6.5です。同アドバイザリは、Host値がRFC 9112の3.2でuri-host [ ":" port ]としてのみ有効であり、uri-hostはRFC 3986の3.2.2の制限されたhost文法に従うと述べたうえで、その文法の外にある文字、とりわけ/ と? と# が再パース時にパスとクエリとフラグメントの境界を動かすと説明しています。なお、この節番号はアドバイザリの記載のままで、Host = uri-host [ ":" port ] というABNF自体はRFC 9110の7.2に置かれています。RFC 9112の3.2はリクエストターゲットを扱う節です。報告者としてx41j、ehhthing、nic-lovinの3名が記載されています。

影響を受けるバージョンとFastAPI経由の間接的な影響

GitHub Security Advisoryが示す影響範囲は、PyPIパッケージ starlette の1.0.0以前です。報告者であるX41 D-Secのアドバイザリ(X41-2026-002)は、より具体的に0.8.3以上1.0.1未満という範囲を挙げています。修正版の1.0.1は2026年5月21日にPyPIへ公開されました。1.0.0の公開は2026年3月22日ですから、1.0系の利用者が影響を受けた期間は2か月ほどですが、0.x系を使い続けている環境は長期にわたって該当します。

Starletteを直接使っている自覚がなくても該当する場合があります。FastAPIは Requestrequest.url の実装をStarletteへ委譲しているため、Starlette 1.0.0以前の上で動くFastAPIアプリは同じ挙動になります。同様に、Starletteを土台にしたASGIアプリケーションであれば、フレームワーク名が何であれ request.url を経由した判断は同じ影響を受けます。

対応の面では、FastAPIの依存指定に上限が無いことが助けになります。執筆時点の最新版であるFastAPI 0.141.1のメタデータは starlette>=0.46.0 であり、上限のピンがありません。FastAPI本体を上げずにStarletteだけを1.0.1以上へ引き上げる対応が取れます。

報告者らが公開しているbadhost.orgは、影響を受けうる下流プロジェクトとしてFastAPI、vLLM、LiteLLM、MCPサーバとFastMCPの統合、Google ADK-Python、Ray Serve、BentoMLを挙げています。これらはいずれもStarletteを土台にしたHTTPサーバを内包する構成です。第三者の集計であり、個々の製品が実際に該当するかは各プロジェクトの告知で確認する必要がありますが、Starletteが機械学習の推論サーバやAIゲートウェイの土台として広く使われている実態は押さえておく価値があります。

ただし、Starletteのバージョンが古いことは必要条件であって十分条件ではありません。実害が出るのは、アプリケーションのどこかで request.url または request.url.path をセキュリティ上の判断に使っている場合です。GitHub Security Advisoryは、最も多い形として「request.url.path にもとづいて特定のパス接頭辞へのアクセスを制御するミドルウェア」を挙げています。

APIの入口で何を検証すべきかという基本は、次の記事で整理しています。

あわせて読みたい

APIセキュリティの基本。認証認可・レート制限・入力検証とOWASP API Top 10で守る

CVSS 6.5という数値の読み方

NVDの一次評価もGitHub Security Advisoryの評価も、CVSS v3.1で6.5です。ベクトルは AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N で、ネットワーク経由、攻撃条件は低く、権限も利用者の操作も不要という前段までは深刻な部類です。数値を押し下げているのは末尾の影響評価で、機密性と完全性がLow、可用性がNoneとされています。

この評価はフレームワークという立場から見た一般的な影響を表しています。Starletteそのものは、認可を迂回された先に何があるかを知りません。認可の先が管理画面のこともあれば、ヘルスチェックのこともあります。CVSSの基本評価は環境に依存しない技術的な深刻度を表すもので、個々の組織の露出状況や資産の価値までは織り込みません。

実運用の重さは、そのアプリが何を守っていたかで決まります。/admin 配下や /internal 配下をミドルウェアで一律にガードしていた構成であれば、そのガードが丸ごと素通しになります。前提となる権限は不要で、リクエストを1本送れる相手なら誰でも成立します。X41 D-Secは自社アドバイザリでこの脆弱性の深刻度をHighと評価しており、報告者らのサイトはさらに強い表現を使っています。数値の差は評価の立場の差です。

実務では、CVSSの環境評価値(Environmental Metrics)を使って自組織の構成に合わせて読み替えるのが素直です。守っている資産の機密性要件が高いのであれば、CRやIRを引き上げた結果として基本評価より高い値になります。6.5という数字だけを見て通常の定例更新へ回す判断は、パス前方一致のミドルウェアを持つアプリには合いません。

NVDに登録されたCVE-2026-48710は、2026年5月26日に公開され、執筆時点のvulnStatusはUndergoing Analysisです。説明文は、1.0.1より前のバージョンでHTTPのHostリクエストヘッダがrequest.urlの再構築に使われる前に検証されていなかったこと、ルーティングアルゴリズムが生のHTTPパスに依存する一方でrequest.urlがHostヘッダから再構築されるため、不正な形式のヘッダによってrequest.url.pathが実際に要求されたパスと異なりうること、そしてrequest.urlにもとづいて制限を適用するミドルウェアやエンドポイントが迂回されうることを述べています。CVSS v3.1のベーススコアはNVD一次評価で6.5(Medium)です。

自分のアプリが該当するかを確かめる手順

該当判定は、インストールされているStarletteのバージョンと、コードが request.url をどう使っているかの二つを見ます。片方だけでは判断できません。

  1. 1

    インストール済みバージョンを実行環境で確認する

    pip show starletteまたはuv pip listを実行し、Versionが1.0.1以上かを見ます。開発環境ではなく、実際に本番で動いている環境の値を見ることが要点です。コンテナで運用しているなら、稼働中のイメージの中で実行します。
  2. 2

    間接依存として入っていないかを調べる

    requirements.txtやpyproject.tomlにstarletteの記載が無くても、fastapiやvllmやlitellmの依存として入ります。uv treeやpipdeptree、poetry show --treeで依存ツリーを展開し、starletteの行を探します。ロックファイルを使っているなら、poetry.lockやuv.lockの中でstarletteのversionを直接確認します。
  3. 3

    request.urlの使用箇所を洗い出す

    リポジトリ全体でrequest.urlを検索し、ログ出力やリダイレクト生成に使っているだけなのか、認可の判定に使っているのかを仕分けます。判定に使っている箇所があれば、それが迂回の対象です。
  4. 4

    ミドルウェアの認可がパス前方一致かを見る

    startswithやin、正規表現によるパス接頭辞の照合で、認証の要否や権限を分岐している箇所を探します。この形がrequest.url.pathと組み合わさっているとき、最も直接的に影響します。
  5. 5

    前段のプロキシがHostを検証しているかを確認する

    リバースプロキシやロードバランサが不正なHostヘッダを拒否または正規化しているなら、到達する前に落ちます。アプリを直接インターネットへ公開している構成や、プロキシが素通しの設定になっている構成では、この緩和は効きません。

実行するコマンドの例は次のとおりです。

# 稼働環境でのバージョン確認
pip show starlette
uv pip list | grep -i starlette

# 間接依存を含む依存ツリーの確認
uv tree | grep -i -A2 -B2 starlette
poetry show --tree | grep -i -A2 -B2 starlette

# ロックファイルからの確認
grep -A3 'name = "starlette"' uv.lock
grep -A3 'name = "starlette"' poetry.lock

# コード側の使用箇所の洗い出し
grep -rn "request\.url" --include="*.py" .
grep -rn "url\.path" --include="*.py" .

依存ツリーの奥にある部品をどう把握し続けるかという話は、次の記事で扱っています。

あわせて読みたい

依存ライブラリ管理とSCA。SBOMで攻撃面を把握する

1.0.1で入った修正の中身

Starlette 1.0.1のリリースノートに書かれた変更は1件だけです。「Ignore malformed Host header when constructing request.url」というタイトルのプルリクエスト#3279で、コミットは764dab0です。変更されたのは starlette/datastructures.py の数行と、テストの追加でした。

追加されたのは、ホスト名を照合するための正規表現と、その判定を通した場合だけHostヘッダを採用するという条件です。

# Starlette 1.0.1 の starlette/datastructures.py より抜粋
# 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)

# ...

if host_header is not None and _HOST_RE.fullmatch(host_header):
    url = f"{scheme}://{host_header}{path}"
elif server is None:
    url = path
else:
    host, port = server
    ...

この正規表現は、英数字とドットとハイフンからなる名前、または角括弧で囲まれたIPv6リテラルを受け入れ、任意で :ポート番号 が続く形だけを許します。RFC 3986の3.2.2が定めるhost文法をそのまま実装したものではなく、それより狭い許可リストです。reg-nameで許されるパーセントエンコード列、アンダースコアなどのunreserved文字、sub-delimsの記号、IPvFuture 形式のIPリテラルは通りません。照合に失敗した場合はHostヘッダを採用せず、elif 以降の分岐、つまりASGIのscopeに入っている server の値へフォールバックします。

挙動として押さえておくべき点が一つあります。不正なHostが送られたとき、1.0.1は400などのエラーを返すのではなく、そのHostを無視して処理を続けます。request.url.path はscopeのパスと一致するようになりますが、request.url.netloc はクライアントが送った値ではなくなります。scopeに server が入っていればそのホストとポート、scope["server"] がNoneの場合はスキームもホストも付かない相対URLになり、netlocは空文字列です。Hostヘッダを使って絶対URLを組み立てている処理があるなら、不正Hostのときの出力が変わることを見込んでおきます。

追加されたテストは、foo/?x=foo/#foo/baruser@foofoo\barfoo bar の6種類のHost値を与え、いずれの場合も u.path/admin のままで、u.netlocexample.com になることを確認する内容です。スラッシュや疑問符やシャープだけでなく、アットマークやバックスラッシュや空白も対象に含めている点から、修正の意図が「境界を動かしうる文字を個別に潰す」ではなく「文法に合う形だけを通す」という許可リストの発想であることが読み取れます。

Starletteのコミット764dab0dcfb9033d75442d7a359645c9f94648c6は「Ignore malformed Host header when constructing request.url (#3279)」というメッセージで、starlette/datastructures.pyに _HOST_RE を追加し、Hostヘッダの採用条件を if host_header is not None: から if host_header is not None and _HOST_RE.fullmatch(host_header): へ変更しています。あわせてtests/test_datastructures.pyに、不正なHost値を与えたときサーバのタプルへフォールバックすることを確認するパラメータ化テストが26行追加されています。このコミットを含む1.0.1は2026年5月21日に公開されました。

恒久対応と暫定回避策

恒久対応はStarletteを1.0.1以上へ上げることです。執筆時点のPyPI上の最新版は1.6.0で、対応Pythonは3.10以上です。1.0.1での変更範囲は上記の数行に限られますが、更新後は認可まわりの回帰テストを行ってください。1.1以降へまとめて上げる場合は各リリースの変更点もあわせて確認します。

pip install -U "starlette>=1.0.1"
uv pip install -U "starlette>=1.0.1"

すぐに上げられない場合の暫定策は3種類あります。出典は同じではありません。GitHub Security Advisoryが対応として直接示しているのは1.0.1への更新で、プロキシによる緩和の条件はImpact欄に書かれています。ASGIのscopeを使う方法と上流でのHost検証はX41 D-Secのアドバイザリが、フレームワークの認可機構へ移す方法は報告者らのサイトが挙げています。

1つ目は、ミドルウェアが参照する値を request.url.path からASGIの scope["path"] へ変えることです。scope["path"] はサーバが受信したリクエストターゲットに由来し、Hostヘッダの影響を受けません。

from starlette.types import ASGIApp, Receive, Scope, Send


class PathGuardMiddleware:
    """パス接頭辞で認可を分岐するミドルウェアの修正例"""

    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        # 変更前: Request(scope).url.path はHostヘッダで書き換えられる
        # path = Request(scope).url.path

        # 変更後: 受信したパスをそのまま使う
        path = scope["path"]

        if path.startswith("/admin") and not self._is_admin(scope):
            ...
        await self.app(scope, receive, send)

2つ目は、前段のリバースプロキシでHostヘッダを検証することです。3つ目は、パス前方一致のミドルウェアによる認可そのものをやめ、FastAPIであれば Depends()Security() による依存性注入で、エンドポイントごとに認可を宣言する形へ移すことです。

from fastapi import APIRouter, Depends, FastAPI, HTTPException, status


async def require_admin(user=Depends(get_current_user)):
    if not user.is_admin:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
    return user


# ルータ単位で依存関係を宣言する。パス文字列の照合に依存しない
admin_router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
app = FastAPI()
app.include_router(admin_router)

対策ごとの効き方と限界を並べると次のようになります。

対策種別効き方限界
Starlette 1.0.1以上へ更新恒久request.url.pathがscopeのパスと一致する依存の更新とテストの時間が要る
ミドルウェアでscope["path"]を使う暫定認可判定が受信値に戻る他のrequest.url利用箇所は残る
プロキシでHostを検証暫定不正Hostがアプリへ届かないプロキシを通らない経路には効かない
Depends()による認可へ移行設計改善パス文字列の照合に依存しなくなる既存コードの改修量が大きい

認証と認可を分けて考える整理は、次の記事で扱っています。

あわせて読みたい

認証と認可の違い。Authentication と Authorization を基礎から整理する

前段の緩和策が成り立つ条件

「プロキシの後ろにいるから大丈夫」という判断は、条件付きでしか成り立ちません。GitHub Security Advisoryは、プロキシやロードバランサの背後にある構成について、そのプロキシが不正なHostヘッダを転送前に拒否または正規化し、かつアプリケーションが他の場所で攻撃者の制御下にあるホストヘッダ(たとえば X-Forwarded-Host)を信頼していない場合にのみ緩和されると書いています。条件は二つあり、両方を満たす必要があります。

Starlette自身が持つ TrustedHostMiddleware は、この緩和の一部を担えます。実装を読むと、Hostヘッダの値をコロンで分割した先頭部分を取り出し、allowed_hosts の各パターンと完全一致またはワイルドカードの後方一致で照合し、どれにも当たらなければ400の Invalid host header を返します。example.com/abc?bar= という値は example.com と一致せず、*.example.com の後方一致にも当たらないため拒否されます。ただし、allowed_hosts を指定せずに使うと既定値が ["*"] となり、その場合は照合そのものが行われません。具体的なホスト名を列挙して初めて防御になります。

nginxを前段に置く場合は、二つの働きを分けて考えます。一つはリクエスト解析時のHost検証です。nginxは受け取ったHostの値を検査し、ホスト名の文法から外れていれば400 Bad Requestで拒否します。ソースの ngx_http_validate_host() を読むと、1.29.4以降は英数字とハイフンとドット、アンダースコアやチルダ、sub-delimsの記号、パーセント記号、角括弧のIPv6リテラルとポート番号だけを許す許可リストになっており、スラッシュ、疑問符、シャープ、アットマーク、バックスラッシュ、空白を含むHostはここで落ちます。1.29.3以前の実装が弾くのはパス区切り文字とNULで、疑問符やシャープは通過します。GitHub Security Advisoryの例はスラッシュを含むため、どちらの実装でも400になります。

もう一つがサーバブロックの選択です。nginxの公式ドキュメントは、ルーティング先を決めるためにHostヘッダだけを検査すると述べ、どのserver_nameにも一致しない場合や、Hostヘッダが無い場合は、そのポートの既定サーバへ渡されると説明しています。こちらで問題になるのは、文法としては正しいものの自組織のものではないHostです。既定サーバを明示して落とす構成は、この経路を塞ぐ多層防御にあたります。自分の環境で攻撃例が400または444になることは、更新前に実際のリクエストを1本送って確かめておきます。

# 一致しないHostを既定サーバで受け止めて落とす
server {
    listen      80 default_server;
    server_name _;
    return      444;
}

# Hostがなく空のケースも落とす
server {
    listen      80;
    server_name "";
    return      444;
}

server {
    listen      80;
    server_name app.example.com;

    location / {
        proxy_pass       http://127.0.0.1:8000;
        proxy_set_header Host $host;
    }
}

proxy_set_header Host $host; の指定にも意味があります。nginxの $host はサーバブロックの選択を経て正規化された値で、$http_host は受信したヘッダの生の値です。上流へ $http_host をそのまま渡す設定になっていると、プロキシが受け入れてしまった値がアプリケーションへ届きます。

注意

プロキシによる緩和は、その経路を通るトラフィックにしか効きません。コンテナ間の内部通信、サービスメッシュを経由しない直接アクセス、開発用に開けたポート、Kubernetesのポートフォワードといった別経路が残っていれば、そこからは不正なHostがそのまま届きます。緩和策を根拠に更新を先送りする場合は、アプリケーションへ到達しうる経路を洗い出したうえで判断してください。

パス前方一致で認可を決める設計の弱さ

今回の脆弱性はStarletteの実装不備ですが、被害の大きさを決めたのはアプリケーション側の設計です。パスの文字列を前方一致で照合して認可を分岐する形は、フレームワークの側に一つでも解釈のずれがあると丸ごと崩れます。

パスという文字列は、正規化の前後で複数の表現を持ちます。/admin/Admin/admin//admin//admin/./admin/foo/../admin、パーセントエンコードした %2fadmin は、どれかの層では同じ資源を指し、別の層では違う文字列として扱われます。ミドルウェアが見る文字列とルータが解決する文字列が同じ経路で作られているという保証は、フレームワークの実装を読んで初めて得られるものです。

より頑健なのは、認可の判断をルーティングの結果に紐づける形です。FastAPIの Depends() はその一例で、どのエンドポイントが選ばれたかが決まった後に依存関係が解決されるため、パス文字列の照合を挟みません。ルータのデコレータやルータ単位の dependencies に認可を宣言しておけば、新しいエンドポイントを追加したときに認可の宣言を書き忘れたかどうかがコードの見た目で分かるという利点もあります。

前方一致のミドルウェアをすぐに廃止できない場合でも、多層にしておく価値はあります。ミドルウェアでの粗いガードに加えて、エンドポイント側でも権限を確認しておけば、片方の解釈がずれても他方が残ります。今回のケースでは、request.url.path を見るミドルウェアだけが唯一の防御だったアプリが最も深く抜かれました。

もう一点、request.url をアクセスログや監査ログに出している場合は、記録される値も攻撃者に操作されうることを踏まえてください。1.0.0以前の環境で残されたログのパスは、実際に処理されたパスと違う可能性があります。事後の調査で scope["path"] 由来の値と突き合わせられるよう、記録元を明示しておくと後で助かります。

LiteLLMとの連鎖が示す、AIゲートウェイの露出

CISAはKEVの記載で、CVE-2026-48710がCVE-2026-42271と連鎖しうると注記しています。CVE-2026-42271はLiteLLMのコマンドインジェクションで、NVDの説明によれば、MCPサーバを保存前にプレビューする2つのエンドポイント POST /mcp-rest/test/connectionPOST /mcp-rest/test/tools/list が、stdioトランスポート用の commandargsenv を含むサーバ設定をリクエストボディで受け付けていました。stdioの設定で呼ばれると接続を試みてコマンドをプロキシホスト上のサブプロセスとして起動するため、有効なプロキシAPIキーを持つ任意の利用者が任意のコマンドを実行できました。影響範囲は1.74.2以上1.83.7未満で、修正は1.83.7です。NVD一次評価のCVSS v3.1は8.8です。

この2件が並ぶ理由は、必要な前提が噛み合うからです。CVE-2026-42271は「認証済みの利用者」を必要とします。CVE-2026-48710は、その認証を担っていた層がパス前方一致のミドルウェアであった場合に、それを外す働きをします。個々のCVSSでは低いほうの脆弱性が、高いほうの前提条件を消してしまう形です。

CVE製品内容KEV追加日
CVE-2026-42271LiteLLMMCPプレビュー用エンドポイントでのコマンド実行2026-06-08
CVE-2026-59822LiteLLMMCP Streamable HTTPエンドポイントの認証不備2026-09-02
CVE-2026-48710StarletteHostヘッダ検証不備によるパス解釈のずれ2026-09-02

2026年9月2日には、LiteLLMのCVE-2026-59822も同時にKEVへ追加されました。NVDの説明によれば、1.84.0より前のLiteLLMのMCP Streamable HTTPエンドポイントで、偽造したAuthorizationヘッダによってOAuth2のパススルー用フォールバック経路が起動し、失敗したキー検証が空の UserAPIKeyAuth() に置き換えられることで、有効なキーなしにMCPのツール実行へ到達できたというものです。修正は1.84.0です。

同じ日に、AIゲートウェイの認証不備と、その土台となるフレームワークのパス解釈のずれが並んで登録されたことは、この領域のスタックがどう積まれているかを示しています。LLMのAPIを束ねるゲートウェイやMCPサーバは、構成によっては認証と認可の層が薄いまま社内ネットワークやインターネットへ置かれます。土台のStarletteに欠陥が出ると、その上に載っている推論サーバやゲートウェイの数だけ影響が広がります。

ログから不正なHostヘッダの痕跡を探す

事後確認の観点は単純で、Hostヘッダにホスト名の文法から外れる文字が含まれているリクエストを探します。具体的には、スラッシュ、疑問符、シャープ、アットマーク、バックスラッシュ、空白です。

前提として、Hostヘッダの生の値がログに残っていない環境が多い点を押さえてください。nginxの既定の combined フォーマットにHostは含まれません。uvicornのアクセスログにも出ません。記録するには、あらかじめログフォーマットへ追加しておく必要があります。ここで $host ではなく $http_host を使うのがポイントで、$host は正規化された後の値なので、攻撃の痕跡である生のヘッダが消えます。

log_format withhost '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    'host="$http_host" ua="$http_user_agent"';

access_log /var/log/nginx/access.log withhost;

すでに記録がある場合の探し方は次のようになります。

# Hostにホスト名として不正な文字が入っている行を抽出する
grep -E 'host="[^"]*[/?#@\\ ][^"]*"' /var/log/nginx/access.log

# 抽出したHost値の分布を見る
grep -oE 'host="[^"]*"' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -50

結果を読むときの注意点が3つあります。1つ目は、正しく構成されたプロキシは不正なHostをそもそも拒否するため、拒否されたリクエストがアプリケーション側のログに残らないことです。プロキシ側のログ、とりわけ400や444を返した行を見る必要があります。2つ目は、痕跡が見つからないことが安全の証明にはならないことです。ログの保持期間が試行の時期より短ければ、記録は残りません。3つ目は、Hostに見慣れない値が入っているだけでは攻撃とは限らないことです。スキャナや誤設定のクライアントでも起こります。

より確度の高い確認は、認可の結果そのものを見ることです。ミドルウェアで守っているはずのパス配下のエンドポイントについて、アプリケーションのログに認可を通過した記録が無いのに処理が実行された痕跡が残っていないかを突き合わせます。エンドポイント側でも認可を確認する多層構成にしておけば、この突き合わせは容易になります。

ヒント

今後に備えるなら、アクセスログに $http_host を含めておく設定を標準にしておくと、この種の事案で調査が可能になります。Hostヘッダはキャッシュポイズニングやパスワードリセットのリンク偽装でも使われるため、記録しておく価値は今回の脆弱性に限りません。

対応チェックリスト

CVE-2026-48710への対応と確認

  • 本番で稼働している環境でpip show starletteまたはuv pip listを実行し、Starletteのバージョンが1.0.1以上であることを確認した
  • FastAPIやvLLM、LiteLLM、MCPサーバなど、Starletteを間接依存として含む構成物を依存ツリーで洗い出し、それぞれの実行環境のバージョンを確認した
  • リポジトリ全体でrequest.urlとurl.pathを検索し、セキュリティ上の判断に使っている箇所を特定した
  • パス接頭辞の照合で認可を分岐しているミドルウェアがある場合、参照する値をscope["path"]へ変更したか、Depends()による認可へ移行した
  • TrustedHostMiddlewareを使っている場合、allowed_hostsに具体的なホスト名を列挙しており、既定値の["*"]のままになっていないことを確認した
  • 前段のリバースプロキシで、server_nameに一致しないHostと空のHostを落とす既定サーバを構成し、上流へは正規化した値を渡していることを確認した
  • アプリケーションへ到達しうる経路のうち、プロキシを通らないもの(コンテナ間通信、開発用ポート、ポートフォワード)を洗い出した
  • アクセスログに$http_hostを含める設定へ変更し、過去ログでHost値にスラッシュ、疑問符、シャープ、アットマーク、バックスラッシュ、空白を含む行を検索した(代表的な文字であり、網羅的な指標ではありません)
  • LiteLLMを運用している場合、CVE-2026-42271とCVE-2026-59822の修正版(それぞれ1.83.7以上、1.84.0以上)を適用済みであることを確認した
  • 更新の適用後、正常なHostでのリクエストと、管理系パスへの認可が期待どおり働くことを、許可された環境の回帰テストで確認した

受信した値で判断するという原則へ戻す

CVE-2026-48710が教えるのは、同じリクエストについて二つの答えを返す部品を持ってはいけない、ということです。ルータは受信したパスで動き、request.url.path は再構築した文字列で動きます。この二つが一致するという暗黙の前提の上にパス前方一致の認可を置いていた場合、前提が崩れた時点でその認可は素通しになります。

Starlette 1.0.1の修正は、Hostヘッダを文法に照らして検証し、外れていればサーバ側の値へ戻すという内容です。数行の変更ですが、発想としては「入力を信じて組み立てる」から「文法に合う形だけを通す」への転換にあたります。同じ発想は、アプリケーション側のコードにも当てはめられます。判断に使う値は、加工した結果ではなく受信した値に近いほうを選びます。判断の対象は、文字列ではなくルーティングの結果に紐づけます。

対応の優先度としては、Starletteを1.0.1以上へ上げることが最短で確実です。そのうえで、request.url を認可に使っている箇所が残っていないかを一度棚卸ししておくと、次に似た脆弱性が出たときの調査時間が変わります。更新の適用と設計の見直しは別の仕事ですが、KEVに載った脆弱性への対応は、後者に手を付ける良い機会でもあります。

出典・参考

この記事をシェア

関連する記事