mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-14 12:48:03 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user