From da3876c168684376029930810743207a1cb26d42 Mon Sep 17 00:00:00 2001 From: Tatlatat Date: Tue, 2 Jun 2026 18:28:12 +0700 Subject: [PATCH] Webhook: block IPv6 SSRF bypasses The webhook URL guard's _ip_is_private() only checks a hardcoded _PRIVATE_NETWORKS list, which misses several addresses that route internally. validate_webhook_url() therefore ALLOWED: - http://[::]/ (IPv6 unspecified, reaches localhost) - http://[::ffff:127.0.0.1]/ (IPv4-mapped IPv6 loopback = 127.0.0.1) - http://[::ffff:169.254.169.254]/ (IPv4-mapped cloud metadata endpoint) The last one is the dangerous case: a webhook pointed at the mapped 169.254.169.254 can pull cloud instance credentials (SSRF -> credential theft). Harden _ip_is_private(): first unwrap IPv4-mapped IPv6 to its embedded IPv4 (addr.ipv4_mapped), then reject via the stdlib address properties (is_private, is_loopback, is_link_local, is_reserved, is_multicast, is_unspecified) in addition to the existing network list. Public addresses still pass. tests/test_webhook_ssrf_resilience.py asserts validate_webhook_url raises for the three IPv6 bypasses plus 127.0.0.1 and 0.0.0.0, and still accepts a public IP literal. The IPv6 cases fail before this change. --- src/webhook_manager.py | 14 ++++++++++++++ tests/test_webhook_ssrf_resilience.py | 28 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/test_webhook_ssrf_resilience.py diff --git a/src/webhook_manager.py b/src/webhook_manager.py index dbcaeef..5e56032 100644 --- a/src/webhook_manager.py +++ b/src/webhook_manager.py @@ -38,6 +38,20 @@ _PRIVATE_NETWORKS = [ def _ip_is_private(addr: ipaddress._BaseAddress) -> bool: + # If the address is IPv4-mapped IPv6, extract and evaluate the embedded IPv4 + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: + addr = addr.ipv4_mapped + + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_reserved + or addr.is_multicast + or addr.is_unspecified + ): + return True + return any(addr in net for net in _PRIVATE_NETWORKS) diff --git a/tests/test_webhook_ssrf_resilience.py b/tests/test_webhook_ssrf_resilience.py new file mode 100644 index 0000000..8557b78 --- /dev/null +++ b/tests/test_webhook_ssrf_resilience.py @@ -0,0 +1,28 @@ +import sys +# conftest.py stubs src.database with a fake module; webhook_manager imports +# from it, so drop the stub here to load the real module under test. +if "src.database" in sys.modules: + del sys.modules["src.database"] + +import pytest +from src.webhook_manager import validate_webhook_url + + +def test_webhook_url_ssrf_mitigation(): + # SSRF bypasses that must be rejected, including IPv6 unspecified and + # IPv4-mapped IPv6 (loopback + cloud metadata). + private_urls = [ + "http://[::]/", + "http://[::ffff:127.0.0.1]/", + "http://[::ffff:169.254.169.254]/", + "http://127.0.0.1/", + "http://0.0.0.0/", + ] + for url in private_urls: + with pytest.raises(ValueError) as exc: + validate_webhook_url(url) + assert "private/internal addresses" in str(exc.value) + + # A clearly public IP literal must still be accepted. + public_url = "http://93.184.216.34/" + assert validate_webhook_url(public_url) == public_url