fix(web): refuse unauthenticated bind on non-loopback address (W5)
In open mode ([web.oidc] unset) the dashboard has no login. Binding a LAN/any address there would expose host-management unauthenticated, so ci web serve now aborts unless the bind host is loopback. Auth-configured deployments bind LAN as before.
This commit is contained in:
@@ -23,7 +23,7 @@ Stato passi (spunta man mano):
|
|||||||
- [x] **W2** — Layer `operations/cache.py` + `operations/host.py`: stato cache cargo, purge, disco, runner, backup template. Refactor `retention.py` (`monitor.py` lasciato invariato — refactor opzionale; `disk_status`/`runner_status` verranno consumati dalla web app in W4).
|
- [x] **W2** — Layer `operations/cache.py` + `operations/host.py`: stato cache cargo, purge, disco, runner, backup template. Refactor `retention.py` (`monitor.py` lasciato invariato — refactor opzionale; `disk_status`/`runner_status` verranno consumati dalla web app in W4).
|
||||||
- [x] **W3** — `operations/audit.py`: append-only JSONL + lettura. Config `[web]` in `config.py`.
|
- [x] **W3** — `operations/audit.py`: append-only JSONL + lettura. Config `[web]` in `config.py`.
|
||||||
- [x] **W4** — App web core: `web/app.py` factory + `commands/web.py` (`ci web serve`). Dashboard **read-only** (VM, cache, disco, runner). Ancora **senza auth**, bind localhost.
|
- [x] **W4** — App web core: `web/app.py` factory + `commands/web.py` (`ci web serve`). Dashboard **read-only** (VM, cache, disco, runner). Ancora **senza auth**, bind localhost.
|
||||||
- [ ] **W5** — Auth: Gitea OIDC (Authlib) + sessione firmata + allowlist utenti. Tutte le route protette.
|
- [x] **W5** — Auth: Gitea OIDC (Authlib) + sessione firmata + allowlist utenti (fail-closed) + secret solo da keyring. Tutte le route dati protette (`/healthz`,`/static`,`/login`,`/auth/callback` aperte). Hardening: `ci web serve` rifiuta bind non-loopback quando `[web.oidc]` assente (open mode solo su localhost).
|
||||||
- [ ] **W6** — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate + CSRF + audit + dry-run. HTMX partials.
|
- [ ] **W6** — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate + CSRF + audit + dry-run. HTMX partials.
|
||||||
- [ ] **W7** — UI dark Gitea-style (asset vendorizzati) + installer ripetibile (`setup-web-linux.sh`, unit systemd, `docs/WEB-UI.md`) + validazione finale (test, coverage, mypy, ruff, smoke manuale).
|
- [ ] **W7** — UI dark Gitea-style (asset vendorizzati) + installer ripetibile (`setup-web-linux.sh`, unit systemd, `docs/WEB-UI.md`) + validazione finale (test, coverage, mypy, ruff, smoke manuale).
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,49 @@ the optional ``[web]`` extra is not installed.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from ci_orchestrator.config import Config
|
||||||
|
|
||||||
|
_LOOPBACK_HOSTNAMES = {"localhost", ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loopback(host: str) -> bool:
|
||||||
|
"""True if ``host`` is a loopback address / ``localhost``.
|
||||||
|
|
||||||
|
Unresolvable hostnames are treated as NON-loopback (conservative — we
|
||||||
|
would rather refuse an ambiguous bind than expose the dashboard).
|
||||||
|
"""
|
||||||
|
if host in _LOOPBACK_HOSTNAMES:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(host).is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_guard_error(host: str, config: Config) -> str | None:
|
||||||
|
"""Refuse an unauthenticated bind on a non-loopback address.
|
||||||
|
|
||||||
|
When ``[web.oidc]`` is not configured the dashboard runs in OPEN mode
|
||||||
|
(no login — see ``web/auth.py``). That is only safe on loopback. Binding
|
||||||
|
a LAN/any address in open mode would expose host-management actions
|
||||||
|
unauthenticated, so it is refused here. Returns an error message to abort
|
||||||
|
with, or ``None`` when the bind is allowed.
|
||||||
|
"""
|
||||||
|
if config.web.oidc is None and not _is_loopback(host):
|
||||||
|
return (
|
||||||
|
f"refusing to bind {host!r} without authentication: [web.oidc] is "
|
||||||
|
"not configured, so the dashboard would be served unauthenticated "
|
||||||
|
"on a non-loopback address. Configure [web.oidc] (Gitea OIDC) or "
|
||||||
|
"bind 127.0.0.1."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@click.group("web")
|
@click.group("web")
|
||||||
def web() -> None:
|
def web() -> None:
|
||||||
@@ -49,6 +90,10 @@ def serve(host: str | None, port: int | None) -> None:
|
|||||||
bind_host = host if host is not None else config.web.bind_host
|
bind_host = host if host is not None else config.web.bind_host
|
||||||
bind_port = port if port is not None else config.web.bind_port
|
bind_port = port if port is not None else config.web.bind_port
|
||||||
|
|
||||||
|
guard_error = _bind_guard_error(bind_host, config)
|
||||||
|
if guard_error is not None:
|
||||||
|
raise click.ClickException(guard_error)
|
||||||
|
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
uvicorn.run(app, host=bind_host, port=bind_port)
|
uvicorn.run(app, host=bind_host, port=bind_port)
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,71 @@ def test_web_group_registered_on_root_cli() -> None:
|
|||||||
def test_config_import_does_not_require_web_extra() -> None:
|
def test_config_import_does_not_require_web_extra() -> None:
|
||||||
"""``Config``/``load_config`` (imported lazily inside serve) is core, not [web]."""
|
"""``Config``/``load_config`` (imported lazily inside serve) is core, not [web]."""
|
||||||
assert Config is not None
|
assert Config is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── W5 security guard: no unauthenticated bind on a non-loopback address ──────
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(*, oidc: bool, tmp: Path) -> Config:
|
||||||
|
from ci_orchestrator.config import (
|
||||||
|
BackendConfig,
|
||||||
|
Paths,
|
||||||
|
WebConfig,
|
||||||
|
WebOidcConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
web_oidc = (
|
||||||
|
WebOidcConfig(issuer="https://gitea.lan", client_id="cid", redirect_url="http://x/cb")
|
||||||
|
if oidc
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return Config(
|
||||||
|
paths=Paths(
|
||||||
|
root=tmp,
|
||||||
|
templates=tmp / "templates",
|
||||||
|
build_vms=tmp / "build-vms",
|
||||||
|
artifacts=tmp / "artifacts",
|
||||||
|
keys=tmp / "keys",
|
||||||
|
),
|
||||||
|
backend=BackendConfig(),
|
||||||
|
web=WebConfig(oidc=web_oidc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost", ""])
|
||||||
|
def test_is_loopback_true(host: str) -> None:
|
||||||
|
assert web_module._is_loopback(host) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["192.168.1.50", "10.0.0.5", "0.0.0.0", "gitea.lan"])
|
||||||
|
def test_is_loopback_false(host: str) -> None:
|
||||||
|
assert web_module._is_loopback(host) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_guard_blocks_lan_bind_without_oidc(tmp_path: Path) -> None:
|
||||||
|
err = web_module._bind_guard_error("192.168.1.50", _cfg(oidc=False, tmp=tmp_path))
|
||||||
|
assert err is not None
|
||||||
|
assert "refusing to bind" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_guard_allows_lan_bind_with_oidc(tmp_path: Path) -> None:
|
||||||
|
assert web_module._bind_guard_error("192.168.1.50", _cfg(oidc=True, tmp=tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_guard_allows_loopback_without_oidc(tmp_path: Path) -> None:
|
||||||
|
assert web_module._bind_guard_error("127.0.0.1", _cfg(oidc=False, tmp=tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_serve_refuses_lan_bind_without_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""End-to-end: ``serve`` aborts (and never starts uvicorn) on an unsafe bind.
|
||||||
|
|
||||||
|
The autouse fixture points config at an empty tmp dir → ``web.oidc`` is
|
||||||
|
``None`` (open mode), so a non-loopback ``--host`` must be refused.
|
||||||
|
"""
|
||||||
|
calls = _install_fake_uvicorn(monkeypatch)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli, ["web", "serve", "--host", "10.0.0.5", "--port", "8800"])
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "refusing to bind" in result.output
|
||||||
|
assert calls == [], "uvicorn.run must never be called on a refused bind"
|
||||||
|
|||||||
Reference in New Issue
Block a user