Files
DECNET/decnet/webhook/client.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

250 lines
8.1 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""HMAC-signed HTTP POST delivery for webhook events.
The delivery function is shared between the worker's normal dispatch
loop and the `/webhooks/{uuid}/test` admin route — same payload shape,
same signing, same headers. Retry policy is configurable by the caller
so manual tests can skip retries entirely while the worker retries
with backoff.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import random
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import uuid4
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")
_DEFAULT_TIMEOUT_S = 10.0
_DEFAULT_RETRY_SCHEDULE = (1.0, 2.0, 4.0)
_JITTER_LOW = 0.8
_JITTER_HIGH = 1.2
_PAYLOAD_VERSION = 1
@dataclass(frozen=True)
class SyntheticEvent:
"""Structural match for decnet.bus.base.Event — avoids importing the
bus dependency into the HTTP egress layer."""
topic: str
type: str
ts: str
id: str
payload: dict[str, Any]
@dataclass
class DeliveryResult:
ok: bool
status_code: Optional[int] = None
error: Optional[str] = None
attempts: int = 0
def _canonical_ts(value: Any) -> str:
"""Normalize bus-event ts (epoch float / ISO str / None) to ISO-8601 UTC."""
if isinstance(value, str) and value:
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
return datetime.now(timezone.utc).isoformat()
def _event_id(event: Any) -> str:
explicit = getattr(event, "id", None)
if isinstance(explicit, str) and explicit:
return explicit
return str(uuid4())
def build_payload(event: Any) -> bytes:
"""Serialize an event to the canonical JSON body sent on the wire.
Stable key order (`orjson.OPT_SORT_KEYS`) matters because the HMAC
signs the exact byte sequence — receivers recomputing the hash must
see the same bytes we did.
"""
body = {
"v": _PAYLOAD_VERSION,
"id": _event_id(event),
"ts": _canonical_ts(getattr(event, "ts", None)),
"topic": getattr(event, "topic", ""),
"type": getattr(event, "type", "") or "",
"payload": getattr(event, "payload", None) or {},
}
return orjson.dumps(body, option=orjson.OPT_SORT_KEYS)
def sign(secret: str, body: bytes) -> str:
"""Return `sha256=<hex>` — the value of the `X-DECNET-Signature` header."""
digest = hmac.new(
secret.encode("utf-8"), body, hashlib.sha256
).hexdigest()
return f"sha256={digest}"
def _build_headers(secret: str, body: bytes, topic: str, event_id: str) -> dict[str, str]:
return {
"Content-Type": "application/json",
"User-Agent": "decnet-webhook/1.0",
"X-DECNET-Signature": sign(secret, body),
"X-DECNET-Event-Id": event_id,
"X-DECNET-Event-Topic": topic,
"X-DECNET-Timestamp": str(int(datetime.now(timezone.utc).timestamp())),
}
def _should_retry(status_code: int) -> bool:
"""Retry on network error, 5xx, and 429. 4xx (other) is terminal —
the receiver is telling us the request itself is wrong; retrying
won't help."""
if status_code == 429:
return True
return status_code >= 500
def _jittered(delay: float) -> float:
# Jitter is a load-smoothing knob, not a secret — non-crypto random is
# fine. Using secrets.SystemRandom here would burn entropy for no gain.
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,
*,
retry_schedule: Optional[list[float] | tuple[float, ...]] = None,
timeout_s: float = _DEFAULT_TIMEOUT_S,
client: Optional[httpx.AsyncClient] = None,
) -> DeliveryResult:
"""POST *event* to *sub['url']* with HMAC signing and bounded retries.
*sub* is a subscription row dict (from `repo.get_webhook_subscription`).
*retry_schedule* is the between-attempt delays in seconds; `None` uses
the default `(1, 2, 4)`, `[]` disables retries entirely (one attempt).
*client* allows tests to inject a mock `httpx.AsyncClient`.
"""
schedule = (
list(retry_schedule) if retry_schedule is not None
else list(_DEFAULT_RETRY_SCHEDULE)
)
max_attempts = 1 + len(schedule)
body = build_payload(event)
topic = getattr(event, "topic", "")
eid = _event_id(event)
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)
last_status: Optional[int] = None
last_error: Optional[str] = None
try:
for attempt in range(1, max_attempts + 1):
try:
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(
ok=True, status_code=resp.status_code, attempts=attempt
)
if not _should_retry(resp.status_code):
return DeliveryResult(
ok=False,
status_code=resp.status_code,
error=f"non-retryable {resp.status_code}",
attempts=attempt,
)
last_error = f"http {resp.status_code}"
except (httpx.RequestError, asyncio.TimeoutError) as e:
last_error = f"{type(e).__name__}: {e}"
last_status = None
if attempt < max_attempts:
await asyncio.sleep(_jittered(schedule[attempt - 1]))
return DeliveryResult(
ok=False,
status_code=last_status,
error=last_error or "exhausted retries",
attempts=max_attempts,
)
finally:
if owns_client:
await client.aclose()