Files
DECNET/tests/updater/test_updater_client_pin.py
anti d80e6aa6d1 fix(security): close MEDIUM ASVS findings — JWT pinning, SSE tickets, SSRF, mTLS pin, rate limits + correctness bugs
Auth (V2.1.1/V3.1.2, V2.1.3, V3.1.1):
- Pin JWT iss/aud/typ at mint and require+verify them at decode; revocation
  (jti denylist + tokens_valid_from) still enforced.
- Change-password now requires min_length=12.
- SSE auth moves off JWT-in-URL to a single-use 60s opaque ticket
  (POST /auth/sse-ticket); raw JWT in query no longer authenticates a stream.
  Removed dead fail-open get_stream_user helper.

Egress (V5.1.1, V9.1.1/V14.1.3):
- Webhook delivery + CRUD reject SSRF destinations (private/loopback/link-local/
  metadata, IPv4-mapped, multi-A-record) via resolved-IP validation, pin to the
  vetted IP, and never auto-follow redirects. Opt-out via DECNET_WEBHOOK_ALLOW_PRIVATE.
- UpdaterClient pins the worker leaf cert SHA-256 against the stored per-host
  fingerprint (fail closed on missing/mismatch); DECNET_VERIFY_HOSTNAME now
  defaults True.

Hardening (V13.1.3, V4.1.4, V13.1.2):
- Rate-limit change-password (5/min), enroll-bundle (10/min), webhook-create
  (20/min), host-delete (20/min) via the existing slowapi limiter.
- Correct false 'global auth middleware' comment; document enroll-bundle proxy
  trust.

Correctness (BUG-7..11):
- BUG-7 unbound bus in finally; BUG-8 apply_ceiling clamps to min(base,ceiling);
  BUG-9 commit before emit; BUG-10 multi-actor rearm for sub-threshold identities;
  BUG-11 normalize naive timestamps to UTC.

Already-closed (no change): V14.1.1, V2.1.2/V3.1.3, V5.1.2. Tests added for
every fix; unanimous adversarial review.
2026-06-10 12:32:15 -04:00

195 lines
6.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""UpdaterClient SHA-256 leaf-cert pinning (master->worker updater channel).
The updater channel pip-installs code as root, so it pins the worker's
updater leaf cert against ``SwarmHost.updater_cert_fingerprint`` and fails
closed on mismatch OR a missing recorded fingerprint.
We don't need the real updater ASGI app: ``UpdaterClient.__aenter__`` runs
``_verify_pin`` which opens its own throwaway TLS connection to extract the
peer leaf cert before any RPC. A minimal threaded mTLS socket server that
simply completes the handshake is enough to exercise the pin.
"""
from __future__ import annotations
import pathlib
import socket
import ssl
import threading
import time
import pytest
from decnet.swarm import client as swarm_client
from decnet.swarm import pki
from decnet.swarm.updater_client import UpdaterClient
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class _MiniTLSServer:
"""Threaded mTLS server that accepts a connection, completes the
handshake (presenting the worker leaf cert), then closes."""
def __init__(self, worker_dir: pathlib.Path, port: int) -> None:
self._ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self._ctx.load_cert_chain(
str(worker_dir / "worker.crt"), str(worker_dir / "worker.key")
)
self._ctx.load_verify_locations(cafile=str(worker_dir / "ca.crt"))
self._ctx.verify_mode = ssl.CERT_REQUIRED
self._sock = socket.socket()
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", port))
self._sock.listen(8)
self._sock.settimeout(0.5)
self._stop = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
def start(self) -> None:
self._thread.start()
def _serve(self) -> None:
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except socket.timeout:
continue
except OSError:
break
try:
tls = self._ctx.wrap_socket(conn, server_side=True)
try:
tls.recv(64)
except OSError:
pass
tls.close()
except OSError:
try:
conn.close()
except OSError:
pass
def stop(self) -> None:
self._stop.set()
try:
self._sock.close()
except OSError:
pass
self._thread.join(timeout=5)
@pytest.fixture
def updater_env(tmp_path: pathlib.Path):
ca_dir = tmp_path / "ca"
pki.ensure_ca(ca_dir)
worker_dir = tmp_path / "updater"
pki.write_worker_bundle(
pki.issue_worker_cert(pki.load_ca(ca_dir), "updater-test", ["127.0.0.1"]),
worker_dir,
)
master_id = swarm_client.ensure_master_identity(ca_dir)
port = _free_port()
server = _MiniTLSServer(worker_dir, port)
server.start()
# Give the listener a moment.
time.sleep(0.1)
try:
yield worker_dir, port, master_id
finally:
server.stop()
@pytest.mark.asyncio
async def test_pin_accepts_matching_fingerprint(updater_env) -> None:
worker_dir, port, master_id = updater_env
expected = pki.fingerprint((worker_dir / "worker.crt").read_bytes())
host = {
"uuid": "h1",
"name": "updater-test",
"address": "127.0.0.1",
"updater_cert_fingerprint": expected,
}
async with UpdaterClient(
host=host, updater_port=port, identity=master_id
) as u:
# Entering the context already ran _verify_pin successfully.
assert u._expected_fingerprint == expected.lower()
@pytest.mark.asyncio
async def test_pin_rejects_mismatch(updater_env) -> None:
_worker_dir, port, master_id = updater_env
host = {
"uuid": "h1",
"name": "updater-test",
"address": "127.0.0.1",
"updater_cert_fingerprint": "0" * 64,
}
with pytest.raises(swarm_client.FingerprintMismatchError):
async with UpdaterClient(host=host, updater_port=port, identity=master_id):
pass
@pytest.mark.asyncio
async def test_pin_rejects_missing_fingerprint(updater_env) -> None:
"""Fail closed: a host with no recorded updater fingerprint is refused
(unlike AgentClient, the updater channel never falls through to CA-only)."""
_worker_dir, port, master_id = updater_env
host = {
"uuid": "h1",
"name": "updater-test",
"address": "127.0.0.1",
"updater_cert_fingerprint": None,
}
with pytest.raises(swarm_client.FingerprintMismatchError):
async with UpdaterClient(host=host, updater_port=port, identity=master_id):
pass
def test_verify_hostname_defaults_to_env_flag(monkeypatch) -> None:
"""The verify_hostname kwarg defaults to DECNET_VERIFY_HOSTNAME, which
now defaults to True (operators opt OUT explicitly)."""
import decnet.env as env
monkeypatch.setattr(env, "DECNET_VERIFY_HOSTNAME", True)
c_default = UpdaterClient(address="127.0.0.1", updater_port=9)
assert c_default._verify_hostname is True
monkeypatch.setattr(env, "DECNET_VERIFY_HOSTNAME", False)
c_off = UpdaterClient(address="127.0.0.1", updater_port=9)
assert c_off._verify_hostname is False
# Explicit kwarg overrides the env default.
c_explicit = UpdaterClient(
address="127.0.0.1", updater_port=9, verify_hostname=True
)
assert c_explicit._verify_hostname is True
@pytest.mark.asyncio
async def test_build_client_constructs_with_flag(updater_env) -> None:
"""_build_client must construct a client for both flag values without
error; check_hostname is wired from self._verify_hostname (verified via
the live handshake in the pin tests above, which use verify_hostname
from the env default)."""
import httpx
_worker_dir, port, master_id = updater_env
for flag in (True, False):
c = UpdaterClient(
address="127.0.0.1", updater_port=port, identity=master_id,
verify_hostname=flag,
)
built = c._build_client(httpx.Timeout(5.0))
assert isinstance(built, httpx.AsyncClient)
assert c._verify_hostname is flag
await built.aclose()