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:
2026-06-10 12:32:15 -04:00
parent 6a8af315fb
commit d80e6aa6d1
37 changed files with 1414 additions and 121 deletions

View File

@@ -22,6 +22,11 @@ import httpx
import orjson
from decnet.logging import get_logger
from decnet.webhook.ssrf import (
ValidatedDestination,
WebhookDestinationError,
validate_webhook_url,
)
log = get_logger("webhook.client")
@@ -121,6 +126,51 @@ def _jittered(delay: float) -> float:
return delay * random.uniform(_JITTER_LOW, _JITTER_HIGH) # nosec B311
def _build_pinned_request(
client: httpx.AsyncClient,
url: str,
dest: ValidatedDestination,
body: bytes,
headers: dict[str, str],
) -> httpx.Request:
"""Build a POST request pinned to a validated IP.
Defeats DNS rebinding: instead of letting httpx re-resolve the hostname
at connect time (which an attacker-controlled DNS could flip to an
internal IP after our check passed), we point the connection at one of
the IPs we already validated, while preserving the original ``Host``
header and TLS SNI so the receiver and certificate validation still see
the real hostname.
"""
pinned_ip = dest.ip_addresses[0]
# httpx brackets IPv6 hosts itself — pass the bare IP.
pinned_url = httpx.URL(url).copy_with(host=pinned_ip)
req_headers = dict(headers)
# Preserve virtual-host routing on the receiver.
req_headers.setdefault("Host", _host_header(dest.host, dest.port, dest.scheme))
# Keep TLS SNI + cert hostname validation bound to the real host, not
# the bare IP we connect to.
extensions = {"sni_hostname": dest.host} if dest.scheme == "https" else {}
return client.build_request(
"POST",
pinned_url,
content=body,
headers=req_headers,
extensions=extensions,
)
def _host_header(host: str, port: int, scheme: str) -> str:
default_port = 443 if scheme == "https" else 80
host_part = f"[{host}]" if ":" in host else host
if port == default_port:
return host_part
return f"{host_part}:{port}"
async def deliver(
sub: dict[str, Any],
event: Any,
@@ -148,6 +198,15 @@ async def deliver(
headers = _build_headers(sub["secret"], body, topic, eid)
url = sub["url"]
# SSRF guard: resolve + validate the destination before any connect.
# Fail closed and treat a forbidden destination as terminal (no retry —
# the URL itself is the problem, not a transient network condition).
try:
dest = validate_webhook_url(url)
except WebhookDestinationError as e:
log.warning("webhook delivery blocked by SSRF guard: %s", e)
return DeliveryResult(ok=False, status_code=None, error=str(e), attempts=0)
owns_client = client is None
if client is None:
client = httpx.AsyncClient(timeout=timeout_s)
@@ -157,7 +216,8 @@ async def deliver(
try:
for attempt in range(1, max_attempts + 1):
try:
resp = await client.post(url, content=body, headers=headers)
request = _build_pinned_request(client, url, dest, body, headers)
resp = await client.send(request, follow_redirects=False)
last_status = resp.status_code
if 200 <= resp.status_code < 300:
return DeliveryResult(