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
This commit is contained in:
2026-07-07 15:37:32 -04:00
parent ee8e9a0247
commit cb48e8a726
7 changed files with 361 additions and 4 deletions
+59
View File
@@ -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
+14 -1
View File
@@ -29,6 +29,13 @@ Optional environment variables:
|----------|---------|---------| |----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) | | `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
| `WEBUI_PORT` | `8765` | Listen port | | `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) ## 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 If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed). 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 ## Tests
```bash ```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
``` ```
+106
View File
@@ -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()
+2
View File
@@ -22,6 +22,8 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(data["service"], "mcp-control-plane-webui") self.assertEqual(data["service"], "mcp-control-plane-webui")
self.assertEqual(data["mode"], "read-only-mvp") self.assertEqual(data["mode"], "read-only-mvp")
self.assertIn("timestamp", data) self.assertIn("timestamp", data)
self.assertIn("deployment", data)
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
def test_home_renders(self): def test_home_renders(self):
response = self.client.get("/") response = self.client.get("/")
+14
View File
@@ -2,16 +2,30 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import uvicorn import uvicorn
from webui.app import create_app 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: def main() -> None:
host = os.environ.get("WEBUI_HOST", "127.0.0.1") host = os.environ.get("WEBUI_HOST", "127.0.0.1")
port = int(os.environ.get("WEBUI_PORT", "8765")) port = int(os.environ.get("WEBUI_PORT", "8765"))
_validate_bind_host(host)
uvicorn.run(create_app(), host=host, port=port, log_level="info") uvicorn.run(create_app(), host=host, port=port, log_level="info")
+11 -3
View File
@@ -9,6 +9,7 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list 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: async def health(_request: Request) -> JSONResponse:
bind_host = _request.app.state.webui_bind_host
return JSONResponse({ return JSONResponse({
"status": "ok", "status": "ok",
"service": "mcp-control-plane-webui", "service": "mcp-control-plane-webui",
"mode": "read-only-mvp", "mode": "read-only-mvp",
"timestamp": datetime.now(timezone.utc).isoformat(), "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) 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.""" """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, debug=False,
routes=[ routes=[
Route("/", home, methods=["GET"]), Route("/", home, methods=["GET"]),
@@ -177,4 +183,6 @@ def create_app() -> Starlette:
Route("/leases", leases, methods=["GET"]), Route("/leases", leases, methods=["GET"]),
], ],
exception_handlers={405: method_not_allowed}, exception_handlers={405: method_not_allowed},
) )
app.state.webui_bind_host = resolved_bind
return app
+155
View File
@@ -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."
),
}