From de79c91c591ffcda06e1c32f92b6a45ae1efb765 Mon Sep 17 00:00:00 2001 From: Paul Calnon Date: Mon, 6 Jul 2026 19:47:07 -0500 Subject: [PATCH] fix(security): add uvicorn-CLI bind-parity shim so the SEC-F22 guard sees the real --host (SEC-F27) canopy's lifespan calls enforce_loopback_bind_guard(settings.server.host), but a `uvicorn main:app --host X` launch binds X without ever setting JUNIPER_CANOPY_SERVER__HOST, so the guard read the loopback default and passed while uvicorn bound a public socket -- an undefended bypass (the root cause of SEC-F23/F24). juniper-cascor already closes this via _settings_with_uvicorn_cli_bind; canopy had no equivalent. Add settings_with_uvicorn_cli_bind() to security.py (with _cli_option_value), mirror cascor's shim adapted to canopy's nested settings.server + main:app entrypoint, and apply it in main.py right after get_settings() so every consumer (guard, logs, app.state) sees the real bind. A `python main.py` launch has no uvicorn CLI args, so it is a no-op there. Generic defense: closes the bypass class, not just the three launchers fixed in the loopback-default PR. Tests: 7 new cases in TestUvicornCliBindParity (no-op path, --host/--port overlay in 3 argv forms, non-integer --port ignored, and end-to-end guard-refuses-on-CLI-host). Full bind-guard suite green; black/isort/flake8/mypy/bandit clean. See juniper-ml notes/JUNIPER_2026-07-06_JUNIPER-ECOSYSTEM_LAUNCH-PATH-BIND-AUDIT.md (SEC-F27). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LX5ToumBaABH3QutQC86sM --- src/main.py | 8 ++++ src/security.py | 56 ++++++++++++++++++++++++++ src/tests/unit/test_bind_guard.py | 66 ++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 1742ec11..36b9c84c 100644 --- a/src/main.py +++ b/src/main.py @@ -98,6 +98,14 @@ # Initialize configuration settings = get_settings() +# SEC-F27: overlay any uvicorn CLI --host/--port onto settings so the SEC-F22 bind +# guard (in lifespan) evaluates the real bind even when canopy is launched via +# `uvicorn main:app --host ...` rather than `python main.py` (no-op for the latter). +# juniper-ml notes/JUNIPER_2026-07-06_JUNIPER-ECOSYSTEM_LAUNCH-PATH-BIND-AUDIT.md (SEC-F27). +from security import settings_with_uvicorn_cli_bind + +settings = settings_with_uvicorn_cli_bind(settings) + # Application version from package metadata try: APP_VERSION = importlib.metadata.version("juniper-canopy") diff --git a/src/security.py b/src/security.py index 415c4020..d1ee5620 100644 --- a/src/security.py +++ b/src/security.py @@ -10,15 +10,20 @@ import hmac import ipaddress import secrets +import sys import time from collections import defaultdict from threading import Lock +from typing import TYPE_CHECKING, cast from fastapi import HTTPException, Request, status from fastapi.security import APIKeyHeader from secrets_util import get_secret +if TYPE_CHECKING: + from settings import Settings + api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) # #2a: per-process token marking canopy's OWN server-side self-calls — the Dash @@ -471,3 +476,54 @@ def enforce_loopback_bind_guard(host: str, *, loopback_publish_attested: bool, a if logger is not None: logger.critical(message) raise NonLoopbackBindError(message) + + +def _cli_option_value(argv: list[str], option: str) -> str | None: + """Return a CLI option value from ``--name value`` or ``--name=value``.""" + prefix = f"{option}=" + for index, arg in enumerate(argv): + if arg.startswith(prefix): + return arg[len(prefix) :] + if arg == option and index + 1 < len(argv): + return argv[index + 1] + return None + + +def settings_with_uvicorn_cli_bind(settings: "Settings", argv: list[str] | None = None) -> "Settings": + """Overlay a uvicorn CLI ``--host`` / ``--port`` onto settings for bind-guard parity. + + ``uvicorn main:app --host 0.0.0.0`` is a supported launch path. uvicorn consumes + ``--host`` itself and never sets ``JUNIPER_CANOPY_SERVER__HOST``, so the SEC-F22 + guard -- which reads ``settings.server.host`` -- would see the loopback default + while uvicorn binds a public socket (the SEC-F23 / SEC-F27 bypass class). Mirror + the CLI bind host/port into a transient settings copy before ``main.lifespan`` runs + :func:`enforce_loopback_bind_guard`, so the guard evaluates the *real* bind on the + ``uvicorn main:app`` path too -- the parity juniper-cascor already has via its + ``_settings_with_uvicorn_cli_bind``. + + A ``python main.py`` launch carries no uvicorn CLI bind args, so this is a no-op + there (host/port stay settings-driven). Design-of-record: juniper-ml + ``notes/JUNIPER_2026-07-06_JUNIPER-ECOSYSTEM_LAUNCH-PATH-BIND-AUDIT.md`` (SEC-F27). + """ + args = list(sys.argv if argv is None else argv) + if not any("uvicorn" in arg for arg in args[:2]) and "main:app" not in args: + return settings + + updates: dict[str, object] = {} + host = _cli_option_value(args, "--host") + if host: + updates["host"] = host + + port = _cli_option_value(args, "--port") + if port is not None: + try: + updates["port"] = int(port) + except ValueError: + # Non-integer --port: uvicorn itself would reject it; leave port + # settings-driven rather than crash the guard-parity path. + pass + + if not updates: + return settings + # pydantic is treated as untyped here, so model_copy() is Any -> cast for mypy. + return cast("Settings", settings.model_copy(update={"server": settings.server.model_copy(update=updates)})) diff --git a/src/tests/unit/test_bind_guard.py b/src/tests/unit/test_bind_guard.py index 22c55d7e..b0a83f06 100644 --- a/src/tests/unit/test_bind_guard.py +++ b/src/tests/unit/test_bind_guard.py @@ -18,7 +18,7 @@ import pytest -from security import NonLoopbackBindError, enforce_loopback_bind_guard, is_loopback_host +from security import NonLoopbackBindError, enforce_loopback_bind_guard, is_loopback_host, settings_with_uvicorn_cli_bind @pytest.mark.unit @@ -161,3 +161,67 @@ def test_settings_default_is_loopback_safe(self): ) is None ) + + +class TestUvicornCliBindParity: + """SEC-F27: a uvicorn CLI --host feeds the SEC-F22 guard via settings parity. + + canopy's guard reads ``settings.server.host``; a ``uvicorn main:app --host X`` + launch binds X without touching that setting, so the guard was blind to it + (SEC-F23). ``settings_with_uvicorn_cli_bind`` overlays the CLI bind onto settings + so the guard evaluates the real host on every launch path. + """ + + def test_python_main_launch_is_noop(self): + from settings import Settings + + s = Settings() + out = settings_with_uvicorn_cli_bind(s, ["main.py"]) + # No uvicorn CLI bind args -> unchanged (host/port stay settings-driven). + assert out.server.host == s.server.host + assert out.server.port == s.server.port + + @pytest.mark.parametrize( + "argv", + [ + ["/env/bin/uvicorn", "main:app", "--host", "0.0.0.0"], + ["/env/bin/uvicorn", "main:app", "--host=0.0.0.0"], + ["/env/bin/python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"], + ], + ) + def test_uvicorn_cli_host_overlays_settings(self, argv): + from settings import Settings + + out = settings_with_uvicorn_cli_bind(Settings(), argv) + assert out.server.host == "0.0.0.0" + + def test_uvicorn_cli_port_overlays_settings(self): + from settings import Settings + + out = settings_with_uvicorn_cli_bind( + Settings(), + ["/env/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9999"], + ) + assert out.server.host == "0.0.0.0" + assert out.server.port == 9999 + + def test_non_integer_port_is_ignored(self): + from settings import Settings + + s = Settings() + out = settings_with_uvicorn_cli_bind(s, ["/env/bin/uvicorn", "main:app", "--port", "notaport"]) + # A bad --port is left settings-driven rather than crashing the parity path. + assert out.server.port == s.server.port + + def test_cli_host_reaches_guard_and_refuses(self): + from settings import Settings + + # The point of SEC-F27: a CLI --host 0.0.0.0 with no attestation must now make + # the guard refuse, exactly as a settings-driven 0.0.0.0 bind would. + overlaid = settings_with_uvicorn_cli_bind(Settings(), ["/env/bin/uvicorn", "main:app", "--host", "0.0.0.0"]) + with pytest.raises(NonLoopbackBindError): + enforce_loopback_bind_guard( + overlaid.server.host, + loopback_publish_attested=overlaid.loopback_publish_attested, + auth_proxy_attested=overlaid.auth_proxy_attested, + )