sec(env): refuse to start master API with footgun public-binding config

Add validate_public_binding() called from the master API lifespan: when
DECNET_API_HOST is non-loopback, refuse to start if DECNET_CORS_ORIGINS
still contains a loopback origin (catches the "operator flipped to
0.0.0.0 to make it work and forgot to update CORS" footgun) or if
DECNET_CANARY_HTTP_BASE is plaintext http:// to a non-loopback host.
Log CRITICAL when DECNET_LIMITER_ENABLED=false on a public binding.
The validator no-ops under pytest so unrelated suites don't trip on it.

Add DECNET_VERIFY_HOSTNAME env knob; AgentClient and UpdaterClient
consult it when verify_hostname is None, giving production deploys
TLS hostname verification on top of the existing CA + fingerprint pin.
Default off so dev enrollments with mismatched SANs keep working.
This commit is contained in:
2026-04-27 21:15:15 -04:00
parent 28e2a93355
commit 1a7da33375
4 changed files with 200 additions and 5 deletions

View File

@@ -0,0 +1,89 @@
"""validate_public_binding refuses footgun configs at master startup.
The validator no-ops under pytest by design (so unit tests in unrelated
modules don't have to set five env vars per fixture); these tests strip
the PYTEST_* vars before calling it so the real code path runs.
"""
from __future__ import annotations
import importlib
import sys
import pytest
def _reimport_env(monkeypatch: pytest.MonkeyPatch):
for mod in list(sys.modules):
if mod == "decnet.env" or mod.startswith("decnet.env."):
sys.modules.pop(mod)
return importlib.import_module("decnet.env")
def _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
import os
for k in list(os.environ):
if k.startswith("PYTEST"):
monkeypatch.delenv(k, raising=False)
def test_validator_noop_on_loopback_binding(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
env = _reimport_env(monkeypatch)
_strip_pytest_vars(monkeypatch)
env.validate_public_binding() # no raise
def test_validator_rejects_loopback_cors_on_public_bind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
env = _reimport_env(monkeypatch)
_strip_pytest_vars(monkeypatch)
with pytest.raises(ValueError, match="loopback origin"):
env.validate_public_binding()
def test_validator_accepts_public_cors_on_public_bind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
env = _reimport_env(monkeypatch)
_strip_pytest_vars(monkeypatch)
env.validate_public_binding() # no raise
def test_validator_rejects_plaintext_canary_on_public_bind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://canary.example.com:8088")
env = _reimport_env(monkeypatch)
_strip_pytest_vars(monkeypatch)
with pytest.raises(ValueError, match="plaintext HTTP"):
env.validate_public_binding()
def test_validator_allows_loopback_canary_even_on_public_bind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Local canary endpoint behind the master is fine; only public-facing
# plaintext is the footgun.
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
env = _reimport_env(monkeypatch)
_strip_pytest_vars(monkeypatch)
env.validate_public_binding() # no raise
def test_validator_skips_under_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
# With PYTEST_* still in env (default), even a misconfigured env passes —
# this is the deliberate bypass so unrelated tests don't trip on it.
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
env = _reimport_env(monkeypatch)
env.validate_public_binding() # no raise — guard short-circuits