Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
56 changes: 56 additions & 0 deletions src/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}))
66 changes: 65 additions & 1 deletion src/tests/unit/test_bind_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Loading