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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
28
tests/test_webhook_ssrf_resilience.py
Normal file
28
tests/test_webhook_ssrf_resilience.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user