From cb48e8a72660a2617c3f19403a251a147b5bce53 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 15:37:32 -0400 Subject: [PATCH] feat(webui): auth and deployment boundary (#435) Add bind-host assessment that refuses 0.0.0.0/:: without override, warns on non-loopback binds, and documents internal-only MVP serving. Health endpoint exposes deployment metadata; docs cover Access/VPN/WARP and runtime env assumptions without embedding secrets in the client. Closes #435 --- docs/webui-deployment.md | 59 +++++++++ docs/webui-local-dev.md | 15 ++- tests/test_webui_deployment_boundary.py | 106 ++++++++++++++++ tests/test_webui_skeleton.py | 2 + webui/__main__.py | 14 +++ webui/app.py | 14 ++- webui/deployment_boundary.py | 155 ++++++++++++++++++++++++ 7 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 docs/webui-deployment.md create mode 100644 tests/test_webui_deployment_boundary.py create mode 100644 webui/deployment_boundary.py diff --git a/docs/webui-deployment.md b/docs/webui-deployment.md new file mode 100644 index 0000000..22b3906 --- /dev/null +++ b/docs/webui-deployment.md @@ -0,0 +1,59 @@ +# Web UI deployment boundary (#435) + +The MCP Control Plane web UI is an **internal operator console**, not a +customer-facing application. The MVP assumes local or trusted-network access +only. + +## MVP deployment model + +- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`) +- **Authentication:** none in MVP — protection comes from network placement +- **Mutations:** read-only routes; gated write actions remain disabled (#434) +- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never + embedded in HTML, JavaScript, or browser storage + +Do **not** expose the UI on the public internet without an access layer. + +## Beyond localhost + +If the UI must be reachable outside the operator laptop: + +1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only + authenticated staff reach the service. +2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::` + without understanding the exposure. +3. Set explicit override env vars only after access controls are in place: + - `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`) + - `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host + +Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`. +Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`. + +## Environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `WEBUI_HOST` | `127.0.0.1` | Bind address | +| `WEBUI_PORT` | `8765` | Listen port | +| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library | +| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path | +| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) | +| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) | +| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind | +| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind | + +Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on +the server when a page needs live Gitea data (e.g. `/queue`). They are not +shipped to the browser. + +## Health / deployment metadata + +`GET /health` includes a `deployment` object with bind disposition, runtime +assumption paths, and the client-secret policy. Use it to verify an instance is +configured for internal-only operation. + +## Non-goals (MVP) + +- Full SSO or session login in the UI +- Hosting on the public internet without Access/VPN/WARP +- Embedding Gitea tokens in the frontend bundle \ No newline at end of file diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 0a5b935..b6a4d7c 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -29,6 +29,13 @@ Optional environment variables: |----------|---------|---------| | `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) | | `WEBUI_PORT` | `8765` | Listen port | +| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root | +| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path | +| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) | +| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) | + +See [webui-deployment.md](webui-deployment.md) for internal-only serving, +Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435). ## Routes (MVP) @@ -82,8 +89,14 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched, If credentials are missing or the fetch fails, the page shows an explicit error instead of an empty queue (fail closed). +## Deployment boundary (#435) + +MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused** +unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless +`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_deployment_boundary.py b/tests/test_webui_deployment_boundary.py new file mode 100644 index 0000000..9f5b625 --- /dev/null +++ b/tests/test_webui_deployment_boundary.py @@ -0,0 +1,106 @@ +"""Tests for web UI deployment and auth boundary (#435).""" +import os +import subprocess +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.deployment_boundary import ( + assess_bind_host, + runtime_assumptions, + scan_text_for_client_secrets, +) + + +class TestBindAssessment(unittest.TestCase): + def test_loopback_is_safe(self): + for host in ("127.0.0.1", "localhost", "::1"): + with self.subTest(host=host): + result = assess_bind_host(host) + self.assertEqual(result.disposition, "safe") + + def test_all_interfaces_refused_by_default(self): + for host in ("0.0.0.0", "::", "*"): + with self.subTest(host=host): + result = assess_bind_host(host) + self.assertEqual(result.disposition, "refuse") + + def test_all_interfaces_allowed_with_override(self): + prior = os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None) + try: + os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = "1" + result = assess_bind_host("0.0.0.0") + self.assertEqual(result.disposition, "warn") + finally: + os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None) + if prior is not None: + os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = prior + + def test_non_loopback_warns_without_override(self): + prior = os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None) + try: + os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None) + result = assess_bind_host("192.168.1.10") + self.assertEqual(result.disposition, "warn") + finally: + if prior is not None: + os.environ["WEBUI_ALLOW_REMOTE_BIND"] = prior + + def test_runtime_assumptions_exclude_secrets(self): + assumptions = runtime_assumptions() + blob = " ".join(str(v) for v in assumptions.values()) + self.assertNotIn("GITEA_TOKEN", blob) + self.assertIn("internal-operator-console", assumptions["deployment_mode"]) + + +class TestDeploymentRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app(bind_host="127.0.0.1")) + + def test_health_includes_deployment_metadata(self): + response = self.client.get("/health") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("deployment", data) + deployment = data["deployment"] + self.assertEqual(deployment["mode"], "internal-operator-console") + self.assertEqual(deployment["mvp_auth"], "none") + self.assertEqual(deployment["bind"]["disposition"], "safe") + self.assertIn("runtime_assumptions", deployment) + + def test_rendered_pages_contain_no_embedded_secrets(self): + for path in ("/", "/projects", "/prompts", "/queue"): + with self.subTest(path=path): + text = self.client.get(path).text + self.assertEqual(scan_text_for_client_secrets(text), []) + + +class TestStartupRefusal(unittest.TestCase): + def test_refuses_all_interface_bind(self): + env = {**os.environ, "WEBUI_HOST": "0.0.0.0"} + env.pop("WEBUI_ALLOW_PUBLIC_BIND", None) + repo_root = Path(__file__).resolve().parent.parent + result = subprocess.run( + [sys.executable, "-m", "webui"], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=3, + ) + self.assertEqual(result.returncode, 1) + + +class TestClientSecretScanner(unittest.TestCase): + def test_detects_token_like_content(self): + findings = scan_text_for_client_secrets("var x = GITEA_TOKEN_PRGS") + self.assertTrue(findings) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 5dd23a1..a3c7ef3 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -22,6 +22,8 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(data["service"], "mcp-control-plane-webui") self.assertEqual(data["mode"], "read-only-mvp") self.assertIn("timestamp", data) + self.assertIn("deployment", data) + self.assertEqual(data["deployment"]["mode"], "internal-operator-console") def test_home_renders(self): response = self.client.get("/") diff --git a/webui/__main__.py b/webui/__main__.py index c55b05e..5c427da 100644 --- a/webui/__main__.py +++ b/webui/__main__.py @@ -2,16 +2,30 @@ from __future__ import annotations +import logging import os import uvicorn from webui.app import create_app +from webui.deployment_boundary import assess_bind_host + +logger = logging.getLogger("webui") + + +def _validate_bind_host(host: str) -> None: + assessment = assess_bind_host(host) + if assessment.disposition == "refuse": + logger.error("webui bind refused: %s", assessment.message) + raise SystemExit(1) + if assessment.disposition == "warn": + logger.warning("webui bind warning: %s", assessment.message) def main() -> None: host = os.environ.get("WEBUI_HOST", "127.0.0.1") port = int(os.environ.get("WEBUI_PORT", "8765")) + _validate_bind_host(host) uvicorn.run(create_app(), host=host, port=port, log_level="info") diff --git a/webui/app.py b/webui/app.py index a97d1ad..b1e9d0f 100644 --- a/webui/app.py +++ b/webui/app.py @@ -9,6 +9,7 @@ from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import Route +from webui.deployment_boundary import deployment_snapshot from webui.layout import render_page from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list @@ -47,11 +48,13 @@ async def home(_request: Request) -> HTMLResponse: async def health(_request: Request) -> JSONResponse: + bind_host = _request.app.state.webui_bind_host return JSONResponse({ "status": "ok", "service": "mcp-control-plane-webui", "mode": "read-only-mvp", "timestamp": datetime.now(timezone.utc).isoformat(), + "deployment": deployment_snapshot(bind_host=bind_host), }) @@ -156,9 +159,12 @@ async def method_not_allowed(request: Request, _exc: Exception) -> Response: return JSONResponse({"error": "not_found"}, status_code=404) -def create_app() -> Starlette: +def create_app(*, bind_host: str | None = None) -> Starlette: """Build the read-only MVP Starlette app.""" - return Starlette( + import os + + resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1") + app = Starlette( debug=False, routes=[ Route("/", home, methods=["GET"]), @@ -177,4 +183,6 @@ def create_app() -> Starlette: Route("/leases", leases, methods=["GET"]), ], exception_handlers={405: method_not_allowed}, - ) \ No newline at end of file + ) + app.state.webui_bind_host = resolved_bind + return app \ No newline at end of file diff --git a/webui/deployment_boundary.py b/webui/deployment_boundary.py new file mode 100644 index 0000000..e2df05c --- /dev/null +++ b/webui/deployment_boundary.py @@ -0,0 +1,155 @@ +"""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." + ), + } \ No newline at end of file