Starlette CVE-2026-48710 Host ヘッダが作る二つ目のパス

Python の ASGI フレームワーク Starlette に、Host リクエストヘッダを検証せずに request.url を組み直していた脆弱性がある。CVE-2026-48710 である。CISA の KEV カタログには 2026年9月2日に「HTTP リクエスト/レスポンススマグリング」として収載され、是正期限は 2026年9月16日とされている。KEV に載っているということは、実際に攻撃で使われたことが確認されているという意味である。

CVSS 3.1 の基本値は 6.5(Medium)で、High には届いていない。それでも取り上げるのは、CVSS が「影響の大きさ」の指標であって「悪用される確率」の指標ではないからだ。点数が足りないという理由で、実際に悪用されているものを見送るのは順序が逆である。

この記事では、なぜ「同じリクエストなのにパスが二本」になるのかを追い、最後に Python の標準ライブラリだけで、その二本が食い違う瞬間を手元で観測するスクリプトを置く。Starlette をインストールする必要はない。

なお、この CVE の攻撃コードは書かない。ここで扱うのは欠陥の形であって、特定のアプリを壊す手順ではない。

NVD が書いていること

NVD の記述はこうだ。

Starlette は軽量な ASGI フレームワーク/ツールキットである。1.0.1 より前のバージョンでは、HTTP の Host リクエストヘッダが検証されないまま request.url の再構成に使われていた。ルーティングの処理は生の HTTP パスを使うのに対し、request.urlHost ヘッダから組み直されるため、細工したヘッダによって request.url.path が実際に要求されたパスと食い違いうる。生の scope のパスではなく request.url を根拠にセキュリティ上の制限をかけているミドルウェアやエンドポイントは、したがって迂回されうる。

適用範囲は 0.8.3 以上 1.0.1 未満である。CWE は CWE-444(一貫性のない解釈による HTTP リクエストスマグリング)

KEV 側の記述はもう少し攻撃者寄りで、「Host 部分にパスを注入し、実際のパスの前に付けることができる」「再構成した URL のパスに認証が依存している場合、認証の迂回に至る」としている。さらに CVE-2026-42271 と連鎖しうるとも書いてある。単体の影響が Low 止まりでも、組み合わせると別の話になるということだ。

「パスが二本ある」とはどういうことか

ASGI のアプリケーションは、リクエストを scope という辞書で受け取る。この中に生の path が入っている。ルーターはこれを見て行き先を決める。

一方 request.url は、その場で組み立て直された URL オブジェクトである。組み立てには scheme / Host ヘッダ / path / query が使われる。つまり request.url は Host ヘッダを材料にしている

ふだんはこの二本が一致する。だから、どちらを見ても同じだと思って書ける。

1
2
3
# よくある書き方。これが問題の入り口になる
if request.url.path.startswith("/admin"):
require_admin(request)

ところが Host は攻撃者が自由に決められるヘッダである。ここにパスらしき文字列を混ぜると、組み直した側だけが別のパスになる。ルーターは生のパスを見て /public へ通すのに、判定のほうは /admin/public を見ている、という状態が作れる。

逆向きにも成り立つ。/admin/... へのアクセスを弾く判定を request.url.path で書いていれば、Host を細工して request.url.path/admin 以外に見せることで、判定だけをすり抜けられる。ルーターは生のパスを見ているので、リクエスト自体は /admin/... に届く。

手元で観測する(標準ライブラリだけ)

Starlette を入れなくても、この食い違いの正体は Python の標準ライブラリで再現できる。URL の組み立ては最終的に urllib.parse の世界の話だからだ。次のスクリプトを host_path.py として保存して実行する。

1
2
3
4
5
6
7
8
9
10
11
12
13
# host_path.py — Host ヘッダに紛れ込んだパスが、組み直した URL のパスを変える
from urllib.parse import urlsplit

RAW_PATH = "/public" # ルーターが見る、生のパス

def reconstructed_path(host_header: str) -> str:
"""scheme + Host ヘッダ + 生のパス から URL を組み直して、そのパスを返す"""
return urlsplit("http://" + host_header + RAW_PATH).path

for host in ["example.com", "example.com/admin", "example.com/admin#"]:
got = reconstructed_path(host)
mark = "一致" if got == RAW_PATH else "食い違い"
print(f"Host: {host!r:28} raw={RAW_PATH!r:10} reconstructed={got!r:20} {mark}")

実行結果はこうなる。

1
2
3
4
$ python3 host_path.py
Host: 'example.com' raw='/public' reconstructed='/public' 一致
Host: 'example.com/admin' raw='/public' reconstructed='/admin/public' 食い違い
Host: 'example.com/admin#' raw='/public' reconstructed='/admin' 食い違い

2行目が KEV の言う「実際のパスの前に付ける」である。Host: example.com/admin を送るだけで、組み直したパスの先頭に /admin が生えている。ルーターが見ている生のパスは /public のままだ。

3行目はもっと露骨だ。末尾に # を足すと、そこから先がフラグメント扱いになり、生のパス /public消える。組み直したパスは /admin だけになる。「/admin を含むなら弾く」という判定も、「/public なら許可する」という判定も、これで意味が変わる。

ここで観測しているのはパーサの素直な挙動であって、Starlette 固有のバグではない。Starlette の欠陥は、この素直な挙動に Host ヘッダをそのまま食わせていたことにある。だから修正の方向も「Host を検証してから組み直す」になる。

直し方

まず 1.0.1 以上へ上げる。これが本筋である。

1
2
pip install -U "starlette>=1.0.1"
pip list | grep -i starlette # 実際に上がったか確認する

pip list での確認を省かないこと。Starlette は FastAPI の土台であり、requirements.txt に直接書いていない環境が多い。上位パッケージを更新しても Starlette が上がるとは限らない。「うちは Starlette を使っていない」と思っている環境こそ、まず確認したほうがよい。Red Hat は自社製品向けに RHSA-2026:22992 を出している。

上げるまでの回避は、コード側で「どちらのパスを見ているか」を直すことである。

1
2
3
4
5
6
7
# 変更前: Host ヘッダから組み直したパスを見ている
if request.url.path.startswith("/admin"):
require_admin(request)

# 変更後: ASGI の生のパスを見る
if request.scope["path"].startswith("/admin"):
require_admin(request)

アプリの手前で Host を正規化するのも有効だ。Starlette には TrustedHostMiddleware がある。

1
2
3
from starlette.middleware.trustedhost import TrustedHostMiddleware

app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com", "*.example.com"])

リバースプロキシやロードバランサ側でホスト名の許可リストを持つのでもよい。ただしこれは補助である。認証の判定そのものを request.url から外すのが本筋で、ホスト名の絞り込みはその上に重ねる層だと考えるべきだ。

侵害の確認

見るのはアクセスログの Host ヘッダである。想定しているホスト名以外、とくに /# を含む Host が記録されていないかを探す。

問題は、多くの環境で Host がログに残っていないことだ。nginx なら次を足す。

1
2
log_format with_host '$remote_addr - $host "$request" $status $body_bytes_sent';
access_log /var/log/nginx/access.log with_host;

$host はリクエストの Host ヘッダに由来する値である。これが入っていないログでは、そもそもこの攻撃が来ていたかどうかを後から判定できない。「痕跡が無い」と「見ていない」は別である。

認証が必要なはずのエンドポイントに、認証なしで 200 が返っている記録があれば、それが直接の証拠になる。

まとめ

  • 同じリクエストに対して、アプリの中にパスが二本あった。ルーターが見る生のパスと、Host ヘッダから組み直した request.url.path である
  • Host は攻撃者が決められる。だから組み直した側だけを別のパスに見せられる
  • request.url で認証判定を書いていた箇所が迂回される。scope["path"] を見るように直すのが本筋
  • CVSS 6.5 でも KEV に載っている。点数ではなく、悪用の事実で優先度を決める

この CVE を含む High / Critical の脆弱性と、AWS / GCP / Azure / Linux それぞれの対応状況は CVE Watch にまとめている。KEV の是正期限つきのものは一覧で追えるようにしてある。

参照した一次情報(2026年9月6日に確認)

  • NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-48710(CVSS 6.5 / Analyzed / lastModified 2026-09-04)
  • CISA KEV カタログ(dateAdded 2026-09-02 / dueDate 2026-09-16 / CWE-444)
  • Starlette の GitHub Security Advisory: GHSA-86qp-5c8j-p5mr