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:
Tatlatat
2026-06-02 18:28:12 +07:00
committed by GitHub
parent 431b98525b
commit da3876c168
2 changed files with 42 additions and 0 deletions

View File

@@ -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)

View 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