mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +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:
@@ -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()
|
||||
Reference in New Issue
Block a user