Skip to content

Commit e2be8ec

Browse files
committed
fix(openapi_plugin): block Azure WireServer and IPv6-embedded private addresses in server URL validation
The server URL validator could be bypassed to reach cloud metadata endpoints: the Azure WireServer IP 168.63.129.16 is publicly routable so it passed the private-address checks, and IPv6 addresses embedding an IPv4 (NAT64, 6to4, Teredo) were classified purely as IPv6, letting link-local and other private IPv4 addresses through. - Denylist the Azure WireServer metadata endpoint in _try_classify_ipv4 and enforce it even when allow_private_network_access is enabled. - Decode IPv4 addresses embedded in IPv6 (6to4 via sixtofour, Teredo via teredo, NAT64 64:ff9b::/96 and 64:ff9b:1::/48) before classification so embedded private addresses are blocked. - Add tests covering the WireServer endpoint, all three IPv6 embedding forms, and the private-network-access override.
1 parent 872d29e commit e2be8ec

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616

1717
DEFAULT_ALLOWED_SCHEME = "https"
1818

19+
# Azure instance metadata service (WireServer). Unlike the AWS/GCP equivalents, this
20+
# address is publicly routable, so it would otherwise pass the private-address checks.
21+
_AZURE_WIRE_SERVER = ipaddress.ip_address("168.63.129.16")
22+
_AZURE_WIRE_SERVER_CATEGORY = "Azure metadata (WireServer)"
23+
24+
# Well-known NAT64 prefixes (RFC 6052): the global 64:ff9b::/96 and the local-use
25+
# 64:ff9b:1::/48. IPv4 addresses embedded in these ranges must be classified too.
26+
_NAT64_PREFIXES = (ipaddress.ip_network("64:ff9b::/96"), ipaddress.ip_network("64:ff9b:1::/48"))
27+
1928

2029
class ServerUrlValidationOptions(KernelBaseModel):
2130
"""Options for validating OpenAPI operation request URLs."""
@@ -59,6 +68,9 @@ async def validate_server_url(
5968
)
6069

6170
if options.allow_private_network_access:
71+
# Allowing access to a private network is not the same as allowing access to
72+
# the host agent's cloud metadata endpoint, which is always blocked.
73+
_reject_cloud_metadata_host(parsed_url)
6274
return
6375

6476
await _ensure_public_host(parsed_url, dns_resolver)
@@ -70,15 +82,37 @@ def try_categorize_non_public_address(
7082
"""Return whether an IP address is non-public and the category when blocked."""
7183
ip_address = ipaddress.ip_address(address)
7284

73-
if isinstance(ip_address, ipaddress.IPv6Address) and ip_address.ipv4_mapped:
74-
ip_address = ip_address.ipv4_mapped
85+
if isinstance(ip_address, ipaddress.IPv6Address):
86+
embedded_ipv4 = _extract_embedded_ipv4(ip_address)
87+
if embedded_ipv4 is not None:
88+
ip_address = embedded_ipv4
89+
elif ip_address.ipv4_mapped:
90+
ip_address = ip_address.ipv4_mapped
7591

7692
if isinstance(ip_address, ipaddress.IPv4Address):
7793
return _try_classify_ipv4(ip_address)
7894

7995
return _try_classify_ipv6(ip_address)
8096

8197

98+
def _extract_embedded_ipv4(address: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None:
99+
"""Decode an IPv4 address embedded in an IPv6 address, if any.
100+
101+
Covers IPv4-mapped (``::ffff:a.b.c.d``), 6to4 (``2002::/16``, RFC 3056), Teredo
102+
(``2001::/32``, RFC 4380) and NAT64 (RFC 6052) addresses. The embedded IPv4 is
103+
classified separately so a private address cannot slip through an otherwise
104+
public-looking IPv6 address.
105+
"""
106+
if address.sixtofour is not None:
107+
return address.sixtofour
108+
if address.teredo is not None:
109+
_, teredo_client = address.teredo
110+
return teredo_client
111+
if any(address in prefix for prefix in _NAT64_PREFIXES):
112+
return ipaddress.ip_address(address.packed[-4:])
113+
return None
114+
115+
82116
def _parse_absolute_url(url: str, option_name: str = "url") -> ParseResult:
83117
parsed_url = urlparse(url)
84118
try:
@@ -191,9 +225,35 @@ def _ensure_public_address(url: str, address: ipaddress.IPv4Address | ipaddress.
191225
)
192226

193227

228+
def _reject_cloud_metadata_host(parsed_url: ParseResult) -> None:
229+
"""Block cloud metadata endpoints even when private network access is allowed.
230+
231+
Only literal IP hosts are checked: private-network mode deliberately does not
232+
resolve hostnames, so a hostname pointing at a metadata endpoint is out of scope.
233+
"""
234+
host = parsed_url.hostname
235+
if host is None:
236+
return
237+
238+
try:
239+
ip_address = ipaddress.ip_address(host)
240+
except ValueError:
241+
return
242+
243+
blocked, category = try_categorize_non_public_address(ip_address)
244+
if blocked and category == _AZURE_WIRE_SERVER_CATEGORY:
245+
raise FunctionExecutionException(
246+
f"The request URI '{parsed_url.geturl()}' is not allowed: the host is the Azure "
247+
f"metadata endpoint (WireServer, {ip_address}), which is blocked even when "
248+
"allow_private_network_access=True to prevent SSRF against cloud metadata services."
249+
)
250+
251+
194252
def _try_classify_ipv4(address: ipaddress.IPv4Address) -> tuple[bool, str]:
195253
b0, b1, b2, _ = address.packed
196254

255+
if address == _AZURE_WIRE_SERVER:
256+
return True, _AZURE_WIRE_SERVER_CATEGORY
197257
if b0 == 0:
198258
return True, "unspecified"
199259
if b0 == 10:

python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
("2001:db8::1", "reserved"),
4444
("::ffff:127.0.0.1", "loopback"),
4545
("::ffff:169.254.169.254", "link-local"),
46+
("168.63.129.16", "Azure metadata (WireServer)"),
47+
("64:ff9b::169.254.169.254", "link-local"),
48+
("64:ff9b::a83f:8110", "Azure metadata (WireServer)"),
49+
("2002:a9fe:a9fe::", "link-local"),
50+
("2001:0:4136:e378:8000:63bf:3fff:fdd2", "reserved"),
4651
],
4752
)
4853
def test_try_categorize_non_public_address(address: str, expected_category: str):
@@ -168,3 +173,34 @@ async def fake_resolver(host: str):
168173

169174
with pytest.raises(FunctionExecutionException, match="returned no addresses"):
170175
await validate_server_url("https://empty-dns.example.com/", dns_resolver=fake_resolver)
176+
177+
178+
async def test_validate_server_url_rejects_literal_azure_wire_server():
179+
with pytest.raises(FunctionExecutionException, match="Azure metadata"):
180+
await validate_server_url("https://168.63.129.16/machine/")
181+
182+
183+
async def test_validate_server_url_rejects_nat64_embedded_link_local():
184+
with pytest.raises(FunctionExecutionException, match="link-local"):
185+
await validate_server_url("https://[64:ff9b::169.254.169.254]/latest/meta-data/")
186+
187+
188+
async def test_validate_server_url_rejects_6to4_embedded_link_local():
189+
with pytest.raises(FunctionExecutionException, match="link-local"):
190+
await validate_server_url("https://[2002:a9fe:a9fe::]/latest/meta-data/")
191+
192+
193+
async def test_validate_server_url_blocks_wireserver_even_with_private_network_access():
194+
options = ServerUrlValidationOptions(allow_private_network_access=True)
195+
196+
with pytest.raises(FunctionExecutionException, match="Azure metadata"):
197+
await validate_server_url("https://168.63.129.16/machine/", options)
198+
199+
200+
async def test_validate_server_url_blocks_hostname_resolving_to_azure_wire_server():
201+
async def fake_resolver(host: str):
202+
assert host == "wireserver-host.example.com"
203+
return ["168.63.129.16"]
204+
205+
with pytest.raises(FunctionExecutionException, match="Azure metadata"):
206+
await validate_server_url("https://wireserver-host.example.com/machine/", dns_resolver=fake_resolver)

0 commit comments

Comments
 (0)