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:
Wes Huber
2026-07-04 09:03:38 -07:00
committed by GitHub
parent 3dd031c139
commit 6114ef0d6d
3 changed files with 324 additions and 18 deletions
+156
View File
@@ -0,0 +1,156 @@
"""Regression: webhook delivery must pin the TCP connect to the SSRF-approved IP.
validate_webhook_url resolves the host to accept/reject, but the delivery
connect previously re-resolved independently — a DNS record flipping between
the two lookups (rebinding) could slip an internal IP past the check. _deliver
now resolves+validates once via _validated_public_ips and pins the connect to
that IP through _PinnedAsyncTransport. These tests drive the real transport
against local servers so the pin is exercised end-to-end, not mocked away.
"""
import asyncio
import http.server
import ipaddress
import socketserver
import threading
import pytest
from tests.helpers.import_state import clear_module, preserve_import_state
import os
import sys
from unittest.mock import patch
with patch.dict(os.environ, {"DATABASE_URL": "sqlite:///:memory:"}), \
preserve_import_state("src.database", "core.database"):
clear_module("src.database")
_core_database = sys.modules.get("core.database")
if _core_database is not None and not getattr(_core_database, "__file__", None):
del sys.modules["core.database"]
import src.webhook_manager as wm
# ---------------------------------------------------------------------------
# _validated_public_ips
# ---------------------------------------------------------------------------
def test_validated_public_ips_rejects_metadata_literal():
with pytest.raises(ValueError):
wm._validated_public_ips("http://169.254.169.254/")
def test_validated_public_ips_rejects_loopback_literal():
with pytest.raises(ValueError):
wm._validated_public_ips("http://127.0.0.1/")
def test_validated_public_ips_returns_public_literal():
ips = wm._validated_public_ips("http://93.184.216.34/")
assert ips == [ipaddress.ip_address("93.184.216.34")]
def test_validated_public_ips_rejects_hostname_resolving_private(monkeypatch):
# Rebinding shape: a hostname that (now) resolves into loopback space.
monkeypatch.setattr(wm, "_resolve_hostname_ips",
lambda h: [ipaddress.ip_address("127.0.0.1")])
with pytest.raises(ValueError):
wm._validated_public_ips("http://evil.rebind.example/")
# ---------------------------------------------------------------------------
# End-to-end: the pinned transport actually routes to the pinned IP
# ---------------------------------------------------------------------------
def _serve(handler):
srv = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, port
def test_pinned_transport_connects_to_pinned_ip():
"""A request whose URL host is a throwaway hostname is still delivered to
the pinned loopback IP — proving the socket destination comes from the pin,
not from resolving the URL host."""
hits = []
class _Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
self.rfile.read(length)
hits.append(self.path)
self.send_response(204)
self.end_headers()
def log_message(self, *a):
pass
srv, port = _serve(_Handler)
try:
ip = ipaddress.ip_address("127.0.0.1")
transport = wm._PinnedAsyncTransport(ip)
async def go():
async with __import__("httpx").AsyncClient(
transport=transport, timeout=5, follow_redirects=False,
) as client:
# Host "unresolvable.invalid" would never resolve; the pin is
# what makes this reach the loopback server on `port`.
return await client.post(
f"http://unresolvable.invalid:{port}/hook", content=b"{}",
)
resp = asyncio.run(go())
assert resp.status_code == 204
assert hits == ["/hook"]
finally:
srv.shutdown()
def test_deliver_pins_to_validated_ip_end_to_end(monkeypatch):
"""Full _deliver path: a hostname that validation resolves to loopback is
pinned to loopback and the local server receives the signed POST."""
received = {}
class _Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
received["body"] = self.rfile.read(length)
received["event"] = self.headers.get("X-Odysseus-Event")
self.send_response(200)
self.end_headers()
def log_message(self, *a):
pass
srv, port = _serve(_Handler)
class _Query:
def filter(self, *a, **k): return self
def update(self, values): return None
class _Db:
def query(self, _m): return _Query()
def commit(self): pass
def rollback(self): pass
def close(self): pass
# Make both the validation resolve and the pin target loopback, and treat
# loopback as allowed for this test (production blocks it — here we only
# want to prove the pin routes to the validated IP).
monkeypatch.setattr(wm, "SessionLocal", lambda: _Db())
monkeypatch.setattr(wm, "_is_private_url", lambda url: False)
monkeypatch.setattr(wm, "_resolve_hostname_ips",
lambda h: [ipaddress.ip_address("127.0.0.1")])
monkeypatch.setattr(wm, "_ip_is_private", lambda a: False)
manager = wm.WebhookManager()
try:
asyncio.run(manager._deliver(
"hook-1", f"http://webhook.test:{port}/cb", "s3cret",
"webhook.test", {"ok": True},
))
assert received.get("event") == "webhook.test"
assert b'"ok": true' in received["body"]
finally:
srv.shutdown()
+16 -13
View File
@@ -96,26 +96,29 @@ async def test_webhook_delivery_uses_naive_utc_timestamps(monkeypatch):
class _Response:
status_code = 204
class _Client:
def __init__(self):
self.content = ""
async def post(self, _url, content, headers):
self.content = content
assert headers["X-Odysseus-Event"] == "webhook.test"
return _Response()
db = _Db()
client = _Client()
monkeypatch.setattr(wm, "SessionLocal", lambda: db)
manager = wm.WebhookManager()
await manager._client.aclose()
manager._client = client
# Replace the pinned-transport send seam so no real socket is opened. The
# public-IP literal below still exercises _validated_public_ips (which pins
# the connect); the captured content proves the body/headers are built.
captured = {}
async def _fake_send(url, body, headers, ip):
captured["content"] = body
captured["ip"] = str(ip)
assert headers["X-Odysseus-Event"] == "webhook.test"
return _Response()
monkeypatch.setattr(manager, "_send_request", _fake_send)
await manager._deliver("hook-1", "http://93.184.216.34/", None, "webhook.test", {"ok": True})
body = json.loads(client.content)
# The delivery must have pinned to the literal public IP from the URL.
assert captured["ip"] == "93.184.216.34"
body = json.loads(captured["content"])
payload_timestamp = datetime.fromisoformat(body["timestamp"])
assert payload_timestamp.tzinfo is None
assert db.updates[0]["last_triggered_at"].tzinfo is None