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:
@@ -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(
|
||||
|
||||
151
decnet/webhook/ssrf.py
Normal file
151
decnet/webhook/ssrf.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""SSRF egress guard for outbound webhook delivery.
|
||||
|
||||
Admin-supplied webhook URLs are attacker-influenceable (anyone able to
|
||||
write a subscription row). Without a destination check the master can be
|
||||
pointed at internal services — cloud metadata (169.254.169.254), the
|
||||
loopback API, RFC1918 hosts — turning the egress path into an SSRF
|
||||
primitive.
|
||||
|
||||
This module resolves the URL host to concrete IPs and rejects any that
|
||||
are private / loopback / link-local / unspecified / reserved / multicast,
|
||||
and rejects non-http(s) schemes. It returns the *validated* IP set so the
|
||||
caller can connect to a checked address rather than re-resolving (which a
|
||||
DNS-rebinding attacker could flip between the validation and the connect).
|
||||
|
||||
Fail closed: the guard is fully active unless the operator explicitly opts
|
||||
out via ``DECNET_WEBHOOK_ALLOW_PRIVATE=true``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
||||
|
||||
|
||||
class WebhookDestinationError(ValueError):
|
||||
"""Raised when a webhook URL resolves to a forbidden destination.
|
||||
|
||||
Subclasses ``ValueError`` so the CRUD layer can turn it into a 422 and
|
||||
the delivery layer can treat it as a terminal (non-retryable) failure.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedDestination:
|
||||
"""Result of a successful guard check.
|
||||
|
||||
``ip_addresses`` is the set of validated literal IPs the URL host
|
||||
resolved to. Connecting to one of these (instead of re-resolving the
|
||||
hostname) closes the DNS-rebinding window.
|
||||
"""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
scheme: str
|
||||
ip_addresses: tuple[str, ...]
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Block anything that is not a routable public address.
|
||||
|
||||
``is_global`` is the inverse of the union we care about, but we spell
|
||||
out the categories so the intent (and the audit mapping) is explicit
|
||||
and so we also catch reserved/multicast that ``is_private`` misses.
|
||||
"""
|
||||
if (
|
||||
ip.is_private # RFC1918 10/8, 172.16/12, 192.168/16, fc00::/7
|
||||
or ip.is_loopback # 127/8, ::1
|
||||
or ip.is_link_local # 169.254/16 (incl. 169.254.169.254), fe80::/10
|
||||
or ip.is_unspecified # 0.0.0.0, ::
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
):
|
||||
return True
|
||||
# IPv4-mapped IPv6 (::ffff:a.b.c.d) hides a v4 address from the checks
|
||||
# above; unwrap and re-check so 127.0.0.1 can't sneak in as ::ffff:7f00:1.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
return _is_forbidden(mapped)
|
||||
return False
|
||||
|
||||
|
||||
def _resolve(host: str, port: int) -> tuple[str, ...]:
|
||||
"""Resolve *host* to the set of literal IPs it points at.
|
||||
|
||||
A bare IP literal short-circuits getaddrinfo. DNS failures raise
|
||||
``WebhookDestinationError`` (fail closed — we never deliver to a host
|
||||
we couldn't resolve and check)."""
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return (host,)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise WebhookDestinationError(
|
||||
f"webhook host {host!r} did not resolve: {exc}"
|
||||
) from exc
|
||||
|
||||
addrs = {str(info[4][0]) for info in infos}
|
||||
if not addrs:
|
||||
raise WebhookDestinationError(f"webhook host {host!r} resolved to nothing")
|
||||
return tuple(sorted(addrs))
|
||||
|
||||
|
||||
def validate_webhook_url(url: str, *, allow_private: Optional[bool] = None) -> ValidatedDestination:
|
||||
"""Validate *url* as a safe webhook egress destination.
|
||||
|
||||
Raises ``WebhookDestinationError`` on a bad scheme, missing host, a host
|
||||
that won't resolve, or any resolved address that is private / loopback /
|
||||
link-local / unspecified / reserved / multicast.
|
||||
|
||||
``allow_private`` defaults to the ``DECNET_WEBHOOK_ALLOW_PRIVATE`` env
|
||||
flag (resolved lazily so tests can monkeypatch the env module). When
|
||||
True the IP-category checks are skipped, but scheme + resolvability are
|
||||
still enforced.
|
||||
"""
|
||||
if allow_private is None:
|
||||
from decnet.env import DECNET_WEBHOOK_ALLOW_PRIVATE
|
||||
|
||||
allow_private = DECNET_WEBHOOK_ALLOW_PRIVATE
|
||||
|
||||
parts = urlsplit(url)
|
||||
scheme = parts.scheme.lower()
|
||||
if scheme not in _ALLOWED_SCHEMES:
|
||||
raise WebhookDestinationError(
|
||||
f"webhook URL scheme {scheme!r} is not allowed (use http/https)"
|
||||
)
|
||||
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
raise WebhookDestinationError("webhook URL has no host")
|
||||
|
||||
port = parts.port or (443 if scheme == "https" else 80)
|
||||
|
||||
resolved = _resolve(host, port)
|
||||
|
||||
if not allow_private:
|
||||
for addr in resolved:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError as exc:
|
||||
raise WebhookDestinationError(
|
||||
f"webhook host {host!r} resolved to non-IP {addr!r}"
|
||||
) from exc
|
||||
if _is_forbidden(ip):
|
||||
raise WebhookDestinationError(
|
||||
f"webhook host {host!r} resolves to forbidden address {addr} "
|
||||
"(private/loopback/link-local/reserved). Set "
|
||||
"DECNET_WEBHOOK_ALLOW_PRIVATE=true to permit internal targets."
|
||||
)
|
||||
|
||||
return ValidatedDestination(
|
||||
host=host, port=port, scheme=scheme, ip_addresses=resolved
|
||||
)
|
||||
Reference in New Issue
Block a user