Skip to content

Commit 2295b1c

Browse files
committed
fix: block SSRF via IPv6 addresses embedding a non-global IPv4
load_web_page vets resolved addresses with `not address.is_global`, but `is_global` on an IPv6 address does not reflect the embedded IPv4 target for NAT64 (`64:ff9b::/96`) and IPv4-compatible (`::a.b.c.d`) addresses. On a network with NAT64 (e.g. IPv6-only / DNS64 clusters), a URL like `http://[64:ff9b::169.254.169.254]/` is treated as global and reaches the internal 169.254.169.254 metadata endpoint. Extract the embedded IPv4 and reject it when it is not globally routable.
1 parent ab839d5 commit 2295b1c

1 file changed

Lines changed: 35 additions & 1 deletion

File tree

src/google/adk/tools/load_web_page.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,42 @@ def _is_blocked_hostname(hostname: str) -> bool:
158158
)
159159

160160

161+
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
162+
163+
164+
def _embedded_ipv4(address: _ResolvedAddress) -> ipaddress.IPv4Address | None:
165+
"""Returns the IPv4 address embedded in an IPv6 address, if any.
166+
167+
``is_global`` on the outer IPv6 address does not reflect the reachability of
168+
the embedded IPv4 target for IPv4-mapped (``::ffff:a.b.c.d``), IPv4-compatible
169+
(``::a.b.c.d``), 6to4 (``2002::/16``) and NAT64 (``64:ff9b::/96``) addresses.
170+
For example ``64:ff9b::169.254.169.254`` is reported as global but, on a
171+
network with NAT64, routes to the internal ``169.254.169.254`` metadata
172+
endpoint. Returning the embedded IPv4 lets the caller vet it directly.
173+
"""
174+
if not isinstance(address, ipaddress.IPv6Address):
175+
return None
176+
if address.ipv4_mapped is not None:
177+
return address.ipv4_mapped
178+
if address.sixtofour is not None:
179+
return address.sixtofour
180+
if address in _NAT64_WELL_KNOWN_PREFIX:
181+
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
182+
# IPv4-compatible ``::a.b.c.d`` (deprecated): top 96 bits zero, low 32 bits a
183+
# non-trivial IPv4 (excluding ``::`` and ``::1``).
184+
packed = int(address)
185+
if packed >> 32 == 0 and (packed & 0xFFFFFFFF) not in (0, 1):
186+
return ipaddress.IPv4Address(packed & 0xFFFFFFFF)
187+
return None
188+
189+
161190
def _is_blocked_address(address: _ResolvedAddress) -> bool:
162-
return not address.is_global
191+
if not address.is_global:
192+
return True
193+
# Reject IPv6 addresses that embed a non-global IPv4 target (NAT64,
194+
# IPv4-compatible, etc.), which `is_global` alone does not catch.
195+
embedded = _embedded_ipv4(address)
196+
return embedded is not None and not embedded.is_global
163197

164198

165199
def _resolve_host_addresses(hostname: str) -> tuple[_ResolvedAddress, ...]:

0 commit comments

Comments
 (0)