Merge commit from fork

- Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) to IPv4
  before checking against blocked networks, preventing blocklist bypass
- Add missing blocked ranges: CGNAT (100.64.0.0/10), IETF Protocol
  Assignments (192.0.0.0/24), Benchmarking (198.18.0.0/15)
- Add comprehensive tests for IPv4-mapped bypass and new blocked ranges
This commit is contained in:
Otto
2026-03-17 13:43:38 +00:00
committed by GitHub
parent 8153306384
commit 2479f3a1c4
2 changed files with 86 additions and 1 deletions

View File

@@ -50,10 +50,13 @@ BLOCKED_IP_NETWORKS = [
# IPv4 Ranges
ipaddress.ip_network("0.0.0.0/8"), # "This" Network
ipaddress.ip_network("10.0.0.0/8"), # Private-Use
ipaddress.ip_network("100.64.0.0/10"), # Shared Address Space (CGNAT, RFC 6598)
ipaddress.ip_network("127.0.0.0/8"), # Loopback
ipaddress.ip_network("169.254.0.0/16"), # Link Local
ipaddress.ip_network("172.16.0.0/12"), # Private-Use
ipaddress.ip_network("192.0.0.0/24"), # IETF Protocol Assignments
ipaddress.ip_network("192.168.0.0/16"), # Private-Use
ipaddress.ip_network("198.18.0.0/15"), # Benchmarking
ipaddress.ip_network("224.0.0.0/4"), # Multicast
ipaddress.ip_network("240.0.0.0/4"), # Reserved for Future Use
# IPv6 Ranges
@@ -71,8 +74,17 @@ HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname patt
def _is_ip_blocked(ip: str) -> bool:
"""
Checks if the IP address is in a blocked network.
IPv4-mapped IPv6 addresses (e.g. ``::ffff:127.0.0.1``) are normalized to
their IPv4 equivalent before checking, so the IPv4 blocklist cannot be
bypassed by encoding a private IPv4 address as IPv6.
"""
ip_addr = ipaddress.ip_address(ip)
# Normalize IPv4-mapped IPv6 → IPv4 so the IPv4 blocklist applies
if isinstance(ip_addr, ipaddress.IPv6Address) and ip_addr.ipv4_mapped:
ip_addr = ip_addr.ipv4_mapped
return any(ip_addr in network for network in BLOCKED_IP_NETWORKS)

View File

@@ -1,7 +1,7 @@
import pytest
from aiohttp import web
from backend.util.request import pin_url, validate_url_host
from backend.util.request import _is_ip_blocked, pin_url, validate_url_host
@pytest.mark.parametrize(
@@ -171,3 +171,76 @@ async def test_large_header_handling():
finally:
await runner.cleanup()
# ---------- IPv4-mapped IPv6 bypass tests (GHSA-8qc5-rhmg-r6r6) ----------
@pytest.mark.parametrize(
"ip, expected_blocked",
[
# IPv4-mapped IPv6 encoding of blocked IPv4 ranges
("::ffff:127.0.0.1", True), # Loopback
("::ffff:10.0.0.1", True), # Private-Use
("::ffff:192.168.1.1", True), # Private-Use
("::ffff:172.16.0.1", True), # Private-Use
("::ffff:169.254.1.1", True), # Link Local
("::ffff:100.64.0.1", True), # CGNAT (RFC 6598)
# Plain IPv4 (should still be blocked)
("127.0.0.1", True),
("10.0.0.1", True),
("100.64.0.1", True), # CGNAT
("192.0.0.1", True), # IETF Protocol Assignments
("198.18.0.1", True), # Benchmarking
# Public IPs (should NOT be blocked)
("8.8.8.8", False),
("1.1.1.1", False),
("::ffff:8.8.8.8", False), # IPv4-mapped but public
# Native IPv6 blocked ranges
("::1", True), # Loopback
("fe80::1", True), # Link-local
("fc00::1", True), # ULA
# Public IPv6 (should NOT be blocked)
("2607:f8b0:4004:800::200e", False), # Google
],
)
def test_is_ip_blocked(ip: str, expected_blocked: bool):
assert _is_ip_blocked(ip) == expected_blocked, (
f"Expected _is_ip_blocked({ip!r}) == {expected_blocked}"
)
@pytest.mark.parametrize(
"raw_url, resolved_ips, should_raise",
[
# IPv4-mapped IPv6 loopback — must be blocked
("mapped-loopback.example.com", ["::ffff:127.0.0.1"], True),
# IPv4-mapped IPv6 private — must be blocked
("mapped-private.example.com", ["::ffff:10.0.0.1"], True),
# CGNAT range — must be blocked
("cgnat.example.com", ["100.64.0.1"], True),
# Mixed: one public, one mapped-private — must be blocked (any blocked = reject)
("mixed.example.com", ["8.8.8.8", "::ffff:192.168.1.1"], True),
# All public — should pass
("public.example.com", ["8.8.8.8", "9.9.9.9"], False),
],
)
async def test_ipv4_mapped_ipv6_bypass(
monkeypatch,
raw_url: str,
resolved_ips: list[str],
should_raise: bool,
):
"""Ensures IPv4-mapped IPv6 addresses are checked against IPv4 blocklist."""
def mock_getaddrinfo(host, port, *args, **kwargs):
return [(None, None, None, None, (ip, port)) for ip in resolved_ips]
monkeypatch.setattr("socket.getaddrinfo", mock_getaddrinfo)
if should_raise:
with pytest.raises(ValueError):
await validate_url_host(raw_url)
else:
url, _, ip_addresses = await validate_url_host(raw_url)
assert ip_addresses # Should have resolved IPs