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
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""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."
|
|
),
|
|
} |