mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
security: pin patched Starlette (>=1.0.1) for CVE-2026-48710 BadHost (#35118)
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Starlette < 1.0.1 is affected by CVE-2026-48710 ("BadHost", CWE-444).
The HTTP Host header was not validated before being used to rebuild
`request.url`, so a malformed Host could make `request.url.path` desync
from the raw ASGI path the router actually dispatched. Middleware and
endpoints that apply path-based authorization off `request.url` (rather
than `scope["path"]`) can therefore be bypassed.
Hermes pulls Starlette transitively, never directly:
- [web] -> fastapi==0.133.1 (starlette>=0.40.0, no upper bound)
- [mcp] -> mcp==1.26.0 + sse-starlette (starlette>=0.27 / >=0.49.1)
- [computer-use] -> mcp==1.26.0
- [dev] -> mcp==1.26.0
A fresh resolve landed starlette 0.52.1 — vulnerable. With no upper
bound on the transitive specs, pip/uv could resolve any pre-1.0.1
release on a fresh install.
Fix: pin starlette==1.0.1 directly in every extra that exposes a
Starlette-backed server surface, regenerate uv.lock (only starlette
moves: 0.52.1 -> 1.0.1, hash-verified), and mirror the pin in the
lazy-install map (tools/lazy_deps.py `tool.dashboard`) so `hermes`
on-demand dashboard installs can't re-resolve a vulnerable version.
1.0.1 is the advisory's named fix floor and the oldest patched release
(more bake time than 1.1.0/1.2.0, which are days old); it satisfies
every carrier constraint and our requires-python>=3.11.
Scope note: this is a dependency-level fix complementing the
application-layer Host-header validator added in #34162
(`hermes_cli/web_server.py` `_is_accepted_host`). Defense in depth at
both the framework and app layers.
Guards: two invariant tests in tests/test_packaging_metadata.py assert
every server-surface extra pins starlette and that pyproject + uv.lock
both resolve >= the 1.0.1 CVE floor — a dropped pin or stale lock fails
in CI instead of shipping the bypass.
Closes #35067
This commit is contained in:
parent
827ce602db
commit
0437137fff
4 changed files with 113 additions and 7 deletions
|
|
@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
|
|||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
|
||||
|
|
@ -108,14 +108,21 @@ pty = [
|
|||
"pywinpty==2.0.15; sys_platform == 'win32'",
|
||||
]
|
||||
honcho = ["honcho-ai==2.0.1"]
|
||||
mcp = ["mcp==1.26.0"]
|
||||
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
|
||||
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
|
||||
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
|
||||
# the ASGI router actually dispatched, so middleware/endpoints that gate on
|
||||
# `request.url` can be bypassed. We pin a patched Starlette directly in every
|
||||
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
|
||||
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
|
||||
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
homeassistant = ["aiohttp==3.13.3"]
|
||||
sms = ["aiohttp==3.13.3"]
|
||||
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
||||
# (curl install script); this extra just pins the MCP client used to talk
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp==1.26.0"]
|
||||
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
|
||||
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
|
||||
|
|
@ -168,7 +175,9 @@ youtube = [
|
|||
"youtube-transcript-api==1.2.4",
|
||||
]
|
||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
|
||||
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
|
||||
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
|
||||
all = [
|
||||
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
||||
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
||||
|
|
|
|||
|
|
@ -115,3 +115,88 @@ def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist():
|
|||
assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, (
|
||||
"MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)"
|
||||
)
|
||||
|
||||
|
||||
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
|
||||
# 1.0.1. Anything below that lets a malformed Host header desync
|
||||
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
|
||||
# authz in middleware/endpoints that gate on ``request.url``. Starlette is a
|
||||
# transitive dep (fastapi in [web]; sse-starlette/mcp in [mcp]/[computer-use]/
|
||||
# [dev]) so we pin it directly in every extra that exposes a server surface and
|
||||
# enforce the floor in both pyproject and the committed lockfile.
|
||||
_STARLETTE_CVE_FLOOR = (1, 0, 1)
|
||||
|
||||
|
||||
def _version_tuple(spec: str) -> tuple[int, ...]:
|
||||
# "1.0.1" -> (1, 0, 1); tolerant of pre/post suffixes by truncating.
|
||||
head = spec.split("+", 1)[0]
|
||||
parts = []
|
||||
for chunk in head.split("."):
|
||||
digits = "".join(ch for ch in chunk if ch.isdigit())
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def test_starlette_pinned_above_cve_2026_48710_floor_in_pyproject():
|
||||
"""Every extra that declares Starlette must pin a patched (>=1.0.1) version.
|
||||
|
||||
Regression guard for #35067 / CVE-2026-48710. A future edit that drops the
|
||||
pin (re-exposing the unbounded transitive ``starlette>=0.27`` from mcp /
|
||||
``>=0.40.0`` from fastapi) or pins a pre-1.0.1 version fails here instead of
|
||||
shipping a Host-header auth-bypass to dashboard / MCP-HTTP users.
|
||||
"""
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
extras = data["project"]["optional-dependencies"]
|
||||
|
||||
found = {}
|
||||
for extra, specs in extras.items():
|
||||
for spec in specs:
|
||||
name = spec.split("==", 1)[0].split(">", 1)[0].split("<", 1)[0].split("[", 1)[0].strip()
|
||||
if name.lower() == "starlette":
|
||||
assert "==" in spec, f"[{extra}] must exact-pin starlette, got {spec!r}"
|
||||
ver = spec.split("==", 1)[1].split(";", 1)[0].strip()
|
||||
found[extra] = ver
|
||||
|
||||
# The four server-surface extras must each carry the direct pin.
|
||||
for extra in ("web", "mcp", "computer-use", "dev"):
|
||||
assert extra in found, (
|
||||
f"[{extra}] no longer pins starlette directly — CVE-2026-48710 "
|
||||
f"regression risk (mcp/fastapi pull it transitively with no upper bound)"
|
||||
)
|
||||
|
||||
for extra, ver in found.items():
|
||||
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
|
||||
f"[{extra}] pins starlette=={ver}, below the CVE-2026-48710 fix "
|
||||
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))}"
|
||||
)
|
||||
|
||||
|
||||
def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
|
||||
"""The committed uv.lock must resolve starlette to a patched version.
|
||||
|
||||
pyproject pins protect the declared extras, but the lockfile is what
|
||||
hash-verified installs (``uv sync --locked``) actually pull. Assert the
|
||||
resolved version is >= the CVE-2026-48710 fix floor so a stale-lock
|
||||
regression can't ship a vulnerable Starlette to users.
|
||||
"""
|
||||
lock = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8")
|
||||
versions = []
|
||||
in_starlette = False
|
||||
for line in lock.splitlines():
|
||||
if line.startswith("[[package]]"):
|
||||
in_starlette = False
|
||||
elif line.strip() == 'name = "starlette"':
|
||||
in_starlette = True
|
||||
elif in_starlette and line.startswith("version = "):
|
||||
versions.append(line.split("=", 1)[1].strip().strip('"'))
|
||||
in_starlette = False
|
||||
|
||||
assert versions, "starlette not found in uv.lock"
|
||||
for ver in versions:
|
||||
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
|
||||
f"uv.lock resolves starlette=={ver}, below the CVE-2026-48710 fix "
|
||||
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the "
|
||||
f"lockfile after bumping the pin"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
|||
"tool.dashboard": (
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
17
uv.lock
generated
17
uv.lock
generated
|
|
@ -1640,6 +1640,7 @@ all = [
|
|||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "youtube-transcript-api" },
|
||||
|
|
@ -1658,6 +1659,7 @@ cli = [
|
|||
]
|
||||
computer-use = [
|
||||
{ name = "mcp" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
daytona = [
|
||||
{ name = "daytona" },
|
||||
|
|
@ -1670,6 +1672,7 @@ dev = [
|
|||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
dingtalk = [
|
||||
|
|
@ -1716,6 +1719,7 @@ matrix = [
|
|||
]
|
||||
mcp = [
|
||||
{ name = "mcp" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
messaging = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -1755,6 +1759,7 @@ termux = [
|
|||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
termux-all = [
|
||||
{ name = "agent-client-protocol" },
|
||||
|
|
@ -1769,6 +1774,7 @@ termux-all = [
|
|||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
tts-premium = [
|
||||
|
|
@ -1781,6 +1787,7 @@ voice = [
|
|||
]
|
||||
web = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
wecom = [
|
||||
|
|
@ -1886,6 +1893,10 @@ requires-dist = [
|
|||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" },
|
||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" },
|
||||
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
|
||||
{ name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" },
|
||||
{ name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" },
|
||||
{ name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" },
|
||||
{ name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" },
|
||||
{ name = "tenacity", specifier = "==9.1.4" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
|
||||
|
|
@ -4084,15 +4095,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue