mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
fix(security): pin webhook delivery to the SSRF-validated IP (DNS rebinding) (#5147)
validate_webhook_url resolves the host to accept/reject, but the delivery connect (httpx.AsyncClient.post) re-resolved independently — a DNS record flipping between the two lookups (rebinding) could slip an internal IP (127.0.0.1 / 169.254.169.254 / LAN) past the check and receive the signed payload. The module docstring already flagged this as only a "partial defense". Resolve + validate once via _validated_public_ips, then pin the delivery TCP connect to that approved IP with an async _PinnedAsyncTransport built on the public httpcore/httpx APIs (mirrors the sync search-fetch pin from #704). The URL, Host header, and TLS SNI are unchanged, so certificate validation and vhost routing still target the original hostname; only the socket destination is pinned. Delivery now uses a per-request pinned client instead of one shared client, so close() is a no-op kept for API compatibility. Adds end-to-end tests that drive the real transport against loopback servers, proving the connect follows the pin rather than re-resolving the URL host. Fixes #5146 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+152
-5
@@ -7,10 +7,12 @@ import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.database import SessionLocal, Webhook
|
||||
@@ -125,6 +127,128 @@ def validate_webhook_url(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _validated_public_ips(url: str) -> list:
|
||||
"""Resolve *url*'s host and return its IPs, raising ValueError if any is
|
||||
private/internal.
|
||||
|
||||
``validate_webhook_url`` resolves the host to decide accept/reject, but the
|
||||
subsequent ``httpx`` connect re-resolves independently — so a DNS record
|
||||
that flips between the two lookups (rebinding) can slip an internal IP past
|
||||
the check. Callers pin the delivery connection to the IP this function
|
||||
returns, closing that TOCTOU. Fail closed: an unresolvable or partly-private
|
||||
result raises rather than returning a usable IP.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").strip()
|
||||
if not hostname:
|
||||
raise ValueError("URL must have a hostname")
|
||||
try:
|
||||
literal = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None:
|
||||
if _ip_is_private(literal):
|
||||
raise ValueError("URL must not point to private/internal addresses")
|
||||
return [literal]
|
||||
addrs = _resolve_hostname_ips(hostname)
|
||||
if not addrs or any(_ip_is_private(a) for a in addrs):
|
||||
raise ValueError("URL must not point to private/internal addresses")
|
||||
return addrs
|
||||
|
||||
|
||||
# httpcore raises its own exception hierarchy; map the ones a simple POST can
|
||||
# surface back to their httpx equivalents so callers' `except httpx.*` blocks
|
||||
# (and sanitize_error) behave exactly as they did with the default transport.
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
|
||||
"""Async network backend that routes every TCP connect to a fixed IP.
|
||||
|
||||
httpcore derives TLS SNI and the ``Host`` header from the request URL, not
|
||||
from the connect host, so pinning only the socket destination keeps
|
||||
certificate validation and vhost routing pointed at the original hostname.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.AnyIOBackend()
|
||||
|
||||
async def connect_tcp(self, host, port, timeout=None, local_address=None,
|
||||
socket_options=None):
|
||||
return await self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return await self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
return await self._real.sleep(seconds)
|
||||
|
||||
|
||||
class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
|
||||
"""httpx transport that pins the TCP connect to a pre-resolved public IP.
|
||||
|
||||
Uses only public ``httpcore`` / ``httpx`` APIs. The request URL is passed
|
||||
through unchanged (Host + SNI stay the original hostname); only the socket
|
||||
destination is pinned, closing the DNS-rebinding TOCTOU between the SSRF
|
||||
check and the connect. HTTP/1.1 only — webhook deliveries are small POSTs.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._pool = httpcore.AsyncConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=False,
|
||||
network_backend=_PinnedAsyncBackend(ip),
|
||||
)
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
core_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
core_resp = await self._pool.handle_async_request(core_req)
|
||||
content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
|
||||
await core_resp.aclose()
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
return httpx.Response(
|
||||
status_code=core_resp.status,
|
||||
headers=core_resp.headers,
|
||||
content=content,
|
||||
extensions=core_resp.extensions,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._pool.aclose()
|
||||
|
||||
|
||||
def validate_events(events_str: str) -> str:
|
||||
"""Validate comma-separated event names. Returns cleaned string."""
|
||||
events = [e.strip() for e in events_str.split(",") if e.strip()]
|
||||
@@ -198,8 +322,11 @@ def sanitize_error(error: str, max_len: int = 200) -> str:
|
||||
|
||||
class WebhookManager:
|
||||
def __init__(self, api_key_manager=None):
|
||||
# Disable redirects to prevent SSRF via redirect chains
|
||||
self._client = httpx.AsyncClient(timeout=10, follow_redirects=False)
|
||||
# No shared client: each delivery builds a short-lived client whose
|
||||
# transport is pinned to the SSRF-approved IP (see _deliver /
|
||||
# _send_request), so a single reusable client can't be pointed at
|
||||
# different pinned hosts. Redirects stay disabled on every delivery
|
||||
# client to prevent SSRF via redirect chains.
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._api_key_manager = api_key_manager
|
||||
# Strong references to in-flight fire-and-forget tasks. asyncio only
|
||||
@@ -262,11 +389,28 @@ class WebhookManager:
|
||||
decrypted = self._decrypt_secret(encrypted_secret)
|
||||
await self._deliver(webhook_id, url, decrypted, "webhook.test", {"message": "Test ping from Odysseus"})
|
||||
|
||||
async def _send_request(self, url: str, body: str, headers: dict,
|
||||
ip: ipaddress._BaseAddress) -> httpx.Response:
|
||||
"""POST *body* to *url* with the TCP connect pinned to *ip*.
|
||||
|
||||
Overridable seam: tests replace this to avoid real sockets. Redirects
|
||||
are disabled so a 3xx can't bounce the delivery to another host.
|
||||
"""
|
||||
transport = _PinnedAsyncTransport(ip)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10, follow_redirects=False, transport=transport,
|
||||
) as client:
|
||||
return await client.post(url, content=body, headers=headers)
|
||||
|
||||
async def _deliver(self, webhook_id: str, url: str, secret: Optional[str], event: str, payload: dict):
|
||||
"""Internal delivery. Never call directly from outside this class (use deliver_test)."""
|
||||
# Re-validate URL at delivery time in case DB was tampered with
|
||||
# Re-validate URL at delivery time in case DB was tampered with, and
|
||||
# capture the exact IPs that passed the check so the connect can be
|
||||
# pinned to one of them (closes the DNS-rebinding TOCTOU: the check
|
||||
# below and the socket connect no longer resolve independently).
|
||||
try:
|
||||
validate_webhook_url(url)
|
||||
pinned_ips = _validated_public_ips(url)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Webhook {webhook_id} has invalid URL, skipping: {e}")
|
||||
return
|
||||
@@ -283,7 +427,7 @@ class WebhookManager:
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resp = await self._client.post(url, content=body, headers=headers)
|
||||
resp = await self._send_request(url, body, headers, pinned_ips[0])
|
||||
db.query(Webhook).filter(Webhook.id == webhook_id).update({
|
||||
"last_triggered_at": _utcnow(),
|
||||
"last_status_code": resp.status_code,
|
||||
@@ -305,4 +449,7 @@ class WebhookManager:
|
||||
db.close()
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
# Delivery clients are per-request and closed via their async context
|
||||
# manager, so there is no long-lived client to tear down here. Kept for
|
||||
# API compatibility with callers (e.g. app shutdown).
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user