Repository navigation
Python: block Azure WireServer and IPv6-embedded IPv4 in the shared public-address check - #14242
ErenAta16 (ErenAta16) wants to merge 7 commits into
Conversation
…er_url_validator server_url_validator is the default SSRF guard for OpenAPI plugin calls, and the URL it sees is shaped by the model through server variables and path parameters. Two categories get through it: 168.63.129.16 -- Azure WireServer, reachable from every Azure VM. It is publicly routable, so none of the _try_classify_ipv4 branches match it. NAT64, 6to4 and Teredo addresses. _try_classify_ipv6 unwraps ::ffff: via ipv4_mapped but does not decode the other encodings, so 64:ff9b::169.254.169.254 and 2002:a9fe:a9fe:: both pass. Add an explicit metadata denylist, checked before the range logic and also when allow_private_network_access is set: reaching a host on your own network and reaching the instance's credential endpoint are different requests, and only the first is what that option is for. RFC1918 and loopback still pass with the option on. Decode the embedded IPv4 for NAT64 (RFC 6052 / RFC 8215), 6to4 (RFC 3056) and Teredo (RFC 4380) and classify that. Only the /96 embedding is decoded: guessing the shorter lengths inside the local-use prefix reads bytes that are not the embedded address, which rejected legitimate NAT64 targets like 64:ff9b:1::1.1.1.1 -- there is a test pinning that those stay allowed. Two existing tests moved off 169.254.169.254 onto a plain link-local address. It is still blocked, now with the more specific metadata message, and the new tests assert on it directly. Closes microsoft#14240
There was a problem hiding this comment.
🟡 Not ready to approve
NAT64 decoding currently applies to all addresses in 64:ff9b:1::/48, which can cause false-positive blocking for non-/96 embeddings and should be restricted to the /96-embedding shape.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR hardens the Python OpenAPI plugin SSRF guard (server_url_validator) by explicitly blocking known cloud metadata/credential endpoints (including Azure WireServer) and by detecting IPv6 encodings that embed an IPv4 target (NAT64/6to4/Teredo/IPv4-mapped) before categorization.
Changes:
- Add an explicit denylist for cloud metadata endpoints and enforce it even when
allow_private_network_access=True. - Decode IPv4 targets embedded in certain IPv6 forms and classify the decoded IPv4 for non-public ranges.
- Expand unit tests to cover cloud metadata endpoints,
allow_private_network_accessbehavior, and IPv6 embedded-IPv4 cases.
File summaries
| File | Description |
|---|---|
python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py |
Adds cloud metadata endpoint blocking and IPv6 embedded-IPv4 decoding for SSRF protection. |
python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py |
Adds/adjusts unit tests for metadata endpoint blocking and embedded IPv4-in-IPv6 rejection/allow cases. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| if any(address in network for network in _NAT64_NETWORKS): | ||
| candidates.append(ipaddress.IPv4Address(bytes(packed[offset] for offset in _NAT64_IPV4_OFFSETS))) | ||
|
|
|
This review comment is correct and I should have answered it sooner. The false positive is real, and it fires on the ordinary case rather than a contrived one. But the suggested remedy would open a hole in the exact thing this PR exists to close, so I want to lay out the measurements before changing anything. The bug, confirmed. RFC 6052 section 2.2 puts the embedded IPv4 in different byte positions per prefix length, and only But restricting the decode to the Today those are blocked, though only by accident: the decode reads a zero suffix and rejects Decoding every RFC 6052 length instead does not work either. I tried it. Each wrong interpretation still yields some IPv4, and those spurious readings classify as blocked often enough to be useless: Skipping all-zero readings recovers the What I think the fix is. Split the two prefixes, because they are not the same kind of thing.
No public address gets mislabelled The cost is explicit and worth stating in the code: legitimate NAT64 traffic through a local-use prefix is refused. For a server-URL allowlist in an OpenAPI plugin I think that is the right side to err on, and it is a stated policy rather than an accident that reports the wrong reason. If anyone disagrees, the alternative is making the local-use prefix length configurable, which seems like a lot of surface for this. I will push that unless someone prefers a different call. Thanks to whoever wired up the review here, the byte-offset observation was exactly right. |
RFC 6052 only places the embedded IPv4 in bytes 12-15 for a /96 embedding. For the shorter lengths those bytes hold the suffix, which the RFC says SHOULD be zero, so decoding them regardless read 0.0.0.0 out of an ordinary public target and rejected it as "unspecified". Declining to decode instead would have swung the error the other way: the metadata address embedded at /48, /56 or /64 would then have passed the check entirely. The prefix length is translator configuration (RFC 6052 section 3.3) and is not carried in the address, so neither direction can be inferred. The well-known prefix 64:ff9b::/96 has a fixed length and keeps its decode. The RFC 8215 local-use prefix is now blocked as a whole instead, which is uniform in both directions and states its reason.
|
Pushed as
Red/green on the new test, reverting only and with the change in place the whole file passes, 84 tests. One test moved rather than being added: |
`IPv6Address.sixtofour` and `.teredo` already do this, and they return None outside their own prefixes, so they carry the membership test with them. That drops two network constants, one offset tuple and the hand-written XOR for the Teredo client address, which is the piece most likely to be restated wrongly. Checked against the previous hand-rolled version on 40000 random addresses per form: no disagreement. The approach is taken from microsoft#14267, which covers the same ground.
|
Flagging an overlap so a reviewer does not read the same fix twice. #14267 covers the same two bypasses this PR does, Azure WireServer being publicly routable and IPv4 smuggled inside NAT64/6to4/Teredo addresses. It was opened on 4 August; this PR and the issue behind it, #14240, are from 29 July. I am not asking anyone to prefer this one. If #14267 is the better patch, say so and I will close this immediately. What would be unhelpful is both sitting in the queue looking like separate work when only one of them needs reviewing. The same thing happened one PR over: #14268 overlaps #14241, which came from #14239 on the same date. |
…dator The classification moved out of server_url_validator.py into semantic_kernel/utils/public_network_address_validator.py, which the OpenAPI plugin and HttpPlugin both call, so the checks now live there. 168.63.129.16 (Azure WireServer) is publicly routable and no range check reaches it, so it is classified explicitly. A NAT64 /96, 6to4 or Teredo IPv6 address carries an IPv4 target inside an address that looks public, so the IPv4 it names is classified too. The RFC 8215 local-use prefix has a translator-specific length, so it is blocked whole instead of decoded. The allow_private_network_access carve-out from the earlier revision is not carried over: main now returns before any address is resolved when that option is set, and changing that contract is a separate decision. The tests build every blocked and public IPv4 target through every wrapper form (mapped, NAT64, 6to4, Teredo) and the local-use prefix at every length.
Motivation and Context
Fixes #14240.
The address check in
semantic_kernel/utils/public_network_address_validator.pyis what the OpenAPI plugin (validate_server_url) andHttpPluginboth rely on to keep a model-influenced URL away from internal hosts. It classifies IPv4 by octet ranges and IPv6 by a few properties, and two kinds of address get past it. Measured on currentmain(3bbde09) with literal-IP URLs throughvalidate_server_url:main169.254.169.254(IMDS)168.63.129.16(Azure WireServer)::ffff:168.63.129.1664:ff9b::169.254.169.254(NAT64, RFC 6052)2002:a9fe:a9fe::(6to4, RFC 3056)2001:0:4136:e378:8000:63bf:3fff:fdd2(Teredo, RFC 4380)64:ff9b::1.1.1.1,2002:0101:0101::(public IPv4 inside)168.63.129.16is publicly routable, so no range check can reach it; it is the Azure host agent's credential endpoint and is reachable from every Azure VM. The IPv6 forms carry an IPv4 target inside an address that looks public. NAT64 needs a gateway on the path and 6to4/Teredo relays are mostly retired, so those three are defence in depth rather than universally exploitable.Description
All of it is in
public_network_address_validator.py, so both callers get it:_try_classify_ipv4classifies168.63.129.16ascloud metadata (Azure WireServer)._try_classify_ipv6decodes the IPv4 carried by a NAT6464:ff9b::/96, 6to4 or Teredo address (sixtofourandteredofrom the standard library, which also undoes Teredo's inverted bits) and classifies it, reporting for examplelink-local (embedded in IPv6).64:ff9b:1::/48is blocked whole. Its embedding length is the translator's configuration (RFC 6052 section 3.3), and decoding bytes 12-15 at a guessed length reads0.0.0.0out of an ordinary target while declining to decode would let the metadata address through at every length but /96.An earlier revision of this PR also kept metadata addresses blocked when
allow_private_network_accessis set.mainnow returns before any address is looked at when that option is on, so that carve-out is not part of this change; whether the option should still cover the credential endpoints is a separate decision for the maintainers.How did you test it?
tests/unit/connectors/openapi_plugin/test_server_url_validator.pygains 81 lines. Every blocked target (169.254.169.254,168.63.129.16,100.100.100.200,192.0.0.192,10.0.0.5,127.0.0.1) is carried through each wrapper form (mapped, NAT64, 6to4, Teredo) and must be blocked; three public IPv4 targets through the same four forms must stay allowed; and the local-use prefix is built at all four RFC 6052 lengths for both a public and a metadata target.On Windows, Python 3.12:
main's source: 33 failed, 167 passedtests/unit/connectors/openapi_pluginplustests/unit/core_plugins/test_http_plugin.py: 588 passed