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.
This commit is contained in:
194
tests/updater/test_updater_client_pin.py
Normal file
194
tests/updater/test_updater_client_pin.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user