"""Internal-only deployment boundary for the operator web UI (#435).""" from __future__ import annotations import ipaddress import os import re from dataclasses import asdict, dataclass from typing import Literal Disposition = Literal["safe", "warn", "refuse"] _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) _ALL_INTERFACE_HOSTS = frozenset({"0.0.0.0", "::", "*"}) _ALLOW_PUBLIC_BIND_ENV = "WEBUI_ALLOW_PUBLIC_BIND" _ALLOW_REMOTE_BIND_ENV = "WEBUI_ALLOW_REMOTE_BIND" _FORBIDDEN_CLIENT_PATTERNS = ( re.compile(r"GITEA_(?:TOKEN|PASS|PASSWORD)", re.I), re.compile(r"Bearer\s+[A-Za-z0-9._\-]{20,}"), re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.I), ) @dataclass(frozen=True) class BindAssessment: host: str disposition: Disposition message: str override_env: str | None = None def _truthy_env(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"} def assess_bind_host(host: str) -> BindAssessment: """Classify a bind host as safe, warn, or refuse (fail closed on 0.0.0.0/::).""" normalized = (host or "").strip().lower() if not normalized: return BindAssessment( host=host, disposition="refuse", message="WEBUI_HOST must not be empty.", ) if normalized in _LOOPBACK_HOSTS: return BindAssessment( host=host, disposition="safe", message="Loopback bind — suitable for local operator console.", ) if normalized in _ALL_INTERFACE_HOSTS: if _truthy_env(_ALLOW_PUBLIC_BIND_ENV): return BindAssessment( host=host, disposition="warn", message=( "Binding all interfaces. Protect with Cloudflare Access, " "WARP, VPN, or equivalent before exposing beyond localhost." ), override_env=_ALLOW_PUBLIC_BIND_ENV, ) return BindAssessment( host=host, disposition="refuse", message=( f"Refusing all-interface bind ({host!r}). Set " f"{_ALLOW_PUBLIC_BIND_ENV}=1 only when fronted by trusted " "network access controls." ), override_env=_ALLOW_PUBLIC_BIND_ENV, ) try: addr = ipaddress.ip_address(normalized) if addr.is_loopback: return BindAssessment( host=host, disposition="safe", message="Loopback bind — suitable for local operator console.", ) except ValueError: pass if _truthy_env(_ALLOW_REMOTE_BIND_ENV): return BindAssessment( host=host, disposition="warn", message=( "Non-loopback bind acknowledged. Restrict to a trusted network " "and add Cloudflare Access, WARP, or VPN if reachable beyond it." ), override_env=_ALLOW_REMOTE_BIND_ENV, ) return BindAssessment( host=host, disposition="warn", message=( f"Non-loopback bind ({host!r}). MVP expects 127.0.0.1; set " f"{_ALLOW_REMOTE_BIND_ENV}=1 to acknowledge a trusted-network bind." ), override_env=_ALLOW_REMOTE_BIND_ENV, ) def runtime_assumptions() -> dict[str, str]: """Documented runtime paths and hosts — never includes secrets.""" repo_root = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() registry = (os.environ.get("WEBUI_PROJECT_REGISTRY") or "").strip() profile_config = (os.environ.get("GITEA_MCP_CONFIG") or "").strip() profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip() return { "deployment_mode": "internal-operator-console", "webui_host_env": "WEBUI_HOST", "webui_port_env": "WEBUI_PORT", "webui_repo_root_env": "WEBUI_REPO_ROOT", "webui_repo_root": repo_root or "(defaults to repository root)", "webui_project_registry_env": "WEBUI_PROJECT_REGISTRY", "webui_project_registry": registry or "(packaged webui/data/projects.registry.json)", "gitea_mcp_config_env": "GITEA_MCP_CONFIG", "gitea_mcp_config": profile_config or "(optional; server-side only)", "gitea_mcp_profile_env": "GITEA_MCP_PROFILE", "gitea_mcp_profile": profile_name or "(optional; server-side only)", "gitea_credentials": "Resolved server-side via gitea_auth; never embedded in HTML/JS", "public_bind_override_env": _ALLOW_PUBLIC_BIND_ENV, "remote_bind_override_env": _ALLOW_REMOTE_BIND_ENV, } def scan_text_for_client_secrets(text: str) -> list[str]: """Return human-readable findings if *text* looks like it embeds secrets.""" findings: list[str] = [] for pattern in _FORBIDDEN_CLIENT_PATTERNS: if pattern.search(text): findings.append(f"matched forbidden client pattern: {pattern.pattern}") return findings def deployment_snapshot(*, bind_host: str | None = None) -> dict[str, object]: host = bind_host if bind_host is not None else os.environ.get("WEBUI_HOST", "127.0.0.1") bind = assess_bind_host(host) return { "mode": "internal-operator-console", "mvp_auth": "none", "bind": asdict(bind), "runtime_assumptions": runtime_assumptions(), "client_secret_policy": ( "No Gitea tokens, passwords, or profile secrets in HTML, JS, " "or browser storage." ), }