diff --git a/pyproject.toml b/pyproject.toml index f3f102b1d..6f565363e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index d72c0224a..fadb022f3 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -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" + ) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index a0926a435..20d68f2f7 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -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] ), } diff --git a/uv.lock b/uv.lock index 24205de86..299c659fd 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]