Skip to content

fix(openapi_plugin): block Azure WireServer and IPv6-embedded private addresses in server URL validation - #14267

Open
Diwakar Ray Yadav (Diwak4r) wants to merge 2 commits into
microsoft:mainfrom
Diwak4r:fix/ssrf-wireserver-metadata-ipv6
Open

fix(openapi_plugin): block Azure WireServer and IPv6-embedded private addresses in server URL validation#14267
Diwakar Ray Yadav (Diwak4r) wants to merge 2 commits into
microsoft:mainfrom
Diwak4r:fix/ssrf-wireserver-metadata-ipv6

Conversation

@Diwak4r

@Diwak4r Diwakar Ray Yadav (Diwak4r) commented Aug 4, 2026

Copy link
Copy Markdown

Description

The OpenAPI plugin server URL validator could be bypassed to reach cloud metadata endpoints in two ways:

  1. Azure WireServer (168.63.129.16) is publicly routable. Unlike the AWS (169.254.169.254) and GCP (metadata.google.internal) endpoints, the Azure WireServer IP falls in no private range, so try_categorize_non_public_address treated it as public and validate_server_url allowed it.
  2. IPv4 addresses embedded in IPv6 were classified purely as IPv6. A link-local or private IPv4 smuggled inside a NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4 (2002::/16), or Teredo (2001::/32) address looked like a benign public IPv6 address and passed validation.

Changes

152071fe — WireServer and embedded IPv4

  • Denylist the Azure WireServer endpoint in _try_classify_ipv4, and enforce it even when allow_private_network_access=True — being allowed to reach your own network is not the same as being allowed to reach the host agent's credential endpoint. The private-mode check only inspects literal IP hosts (private mode deliberately does not resolve hostnames).
  • Decode IPv4 addresses embedded in IPv6 (sixtofour, teredo, and the RFC 6052 NAT64 prefixes) in try_categorize_non_public_address so the embedded address is classified with the IPv4 rules.
  • Add tests covering the WireServer endpoint (literal, DNS-resolved, NAT64- and 6to4-embedded), all three IPv6 embedding forms, and the private-network-access override.

0c24fba5 — the remaining metadata endpoints

The private-mode gate documents that the host agent's cloud metadata endpoint "is always blocked", but it compared the category against _AZURE_WIRE_SERVER_CATEGORY only, so every other metadata endpoint passed:

address category before
168.63.129.16 Azure metadata (WireServer) rejected
169.254.169.254 link-local allowed
169.254.170.2 link-local allowed
100.100.100.200 carrier-grade NAT allowed
fd00:ec2::254 private (IPv6 ULA) allowed

169.254.169.254 is the IMDS address AWS, GCP and Azure share — the endpoint most SSRF write-ups mean — and fd00:ec2::254 is its documented IPv6 form.

The category is not a usable signal here. "link-local", "carrier-grade NAT" and "private (IPv6 ULA)" also cover ordinary private addresses that the option exists to permit, so widening the gate to those categories would remove the feature rather than narrow it. The named addresses are matched instead, with IPv4-mapped and NAT64-embedded IPv6 forms resolved to the address they carry first, so ::ffff:169.254.169.254 and 64:ff9b::169.254.169.254 are caught too.

Ordinary private addresses are unaffected:

https://10.0.0.5/          allowed
https://172.16.0.1/        allowed
https://192.168.1.50/v1    allowed
https://100.64.0.1/        allowed   <- same category as 100.100.100.200

Test plan

pytest tests/unit/connectors/openapi_plugin/test_server_url_validator.py72 passed.

The 10 tests added in 0c24fba5 fail on 152071fe:

6 failed, 66 passed
FAILED ...blocks_cloud_metadata_even_with_private_network_access[https://169.254.169.254/latest/meta-data/]
FAILED ...[https://169.254.170.2/v2/credentials]
FAILED ...[https://100.100.100.200/latest/meta-data/]
FAILED ...[https://[fd00:ec2::254]/latest/meta-data/]
FAILED ...[https://[::ffff:169.254.169.254]/latest/meta-data/]
FAILED ...[https://[64:ff9b::169.254.169.254]/latest/meta-data/]

Fixes #14240


AI-assisted change, built and tested locally; the output above is from actual runs.

Copilot AI lite review requested due to automatic review settings August 4, 2026 08:35
@Diwak4r
Diwakar Ray Yadav (Diwak4r) requested a review from a team as a code owner August 4, 2026 08:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 5 | Confidence: 74% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes, Design Approach


Automated review by Diwak4r's agents

@Diwak4r

Copy link
Copy Markdown
Author

Just verifying this is still in the review queue. All checks green, branch is mergeable. Happy to address any feedback.

@Diwak4r
Diwakar Ray Yadav (Diwak4r) force-pushed the fix/ssrf-wireserver-metadata-ipv6 branch from cf75934 to 6b77c57 Compare August 12, 2026 09:35
ErenAta16 (ErenAta16) added a commit to ErenAta16/semantic-kernel that referenced this pull request Aug 15, 2026
`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.
@ErenAta16

Copy link
Copy Markdown

Heads up on one thing here, because I hit exactly the same bug in #14242 and only found it by measuring.

_NAT64_PREFIXES includes 64:ff9b:1::/48 and _extract_embedded_ipv4 reads address.packed[-4:] for anything inside it. RFC 6052 section 2.2 only puts the embedded IPv4 in the last four bytes for a /96 embedding; for /48, /56 and /64 those bytes are the suffix, which the RFC says SHOULD be zero. So an ordinary public target embedded at any length but /96 decodes as 0.0.0.0 and gets blocked as unspecified:

8.8.8.8 via /96   64:ff9b:1::808:808        -> 8.8.8.8    allowed
8.8.8.8 via /48   64:ff9b:1:808:8:800::     -> 0.0.0.0    blocked
1.1.1.1 via /48   64:ff9b:1:101:1:100::     -> 0.0.0.0    blocked

The tempting fix, only decoding when the address really is /96-shaped, swings it the other way: 169.254.169.254 embedded at /48, /56 or /64 then passes the check entirely, which is worse than the false positive. Decoding all four lengths does not work either, because each wrong reading still produces some IPv4 and those land in blocked ranges often enough to be noise.

The reason none of it works is RFC 6052 section 3.3: the prefix length is configuration held by the translator and is not carried in the address, so there is nothing to infer it from.

What I ended up with, if it is useful to you: keep the decode for 64:ff9b::/96, whose length RFC 6052 section 2.1 fixes, and treat the RFC 8215 local-use /48 as non-public in its own right rather than guessing at its contents. That is uniform in both directions, and it states the policy instead of arriving at it through a zero suffix.

Separately, thank you for address.sixtofour / address.teredo. Mine was doing the offset arithmetic and the Teredo XOR by hand, and yours is plainly better since those properties return None outside their prefixes and carry the membership test with them. I checked the two against each other on 40000 random addresses per form, found no disagreement, and have switched to the standard library ones with a pointer back to this PR.

There is real overlap between this and #14242, which I opened on 29 July. I am not asking you to close anything, that is for the maintainers to sort out, but the NAT64 detail is worth fixing in whichever one moves.

@Diwak4r

Copy link
Copy Markdown
Author

Thanks for the careful read, you're right, and I've applied exactly the approach you suggested.

_extract_embedded_ipv4 now decodes only the well-known 64:ff9b::/96 prefix (RFC 6052 fixes its length, so the last 32 bits are always the embedded IPv4), and the local-use 64:ff9b:1::/48 range (RFC 8215) is blocked as its own non-public category - NAT64 local-use prefix (RFC 8215) - rather than decoded. That closes the /48 smuggling hole (a WireServer or link-local address embedded at /48 is still blocked, as a range policy) while the /48 0.0.0.0 false positives disappear.

Added regression tests for all of it: the local-use range blocks (including 64:ff9b:1:a83f:8110::, WireServer embedded at /48), and 64:ff9b::808:808 / 64:ff9b::101:101 stay allowed through the /96 decode. The address.sixtofour / address.teredo properties are unchanged - agreed they carry the membership test for free.

And thanks for the pointer on #14242 - happy to defer to the maintainers on how to handle the overlap; this is the behavior I'd keep in whichever PR moves forward.

@ErenAta16

Copy link
Copy Markdown

That is the right split, and putting the RFC reasoning in the comment rather than only in the PR body is what will stop someone "simplifying" the two branches back into one later.

Measured the behaviour at d5bd363b rather than reading it:

ranges overlap?  False

64:ff9b::169.254.169.254   /96=True   /48=False   -> 169.254.169.254
64:ff9b::a83f:8110         /96=True   /48=False   -> 168.63.129.16
64:ff9b::808:808           /96=True   /48=False   -> 8.8.8.8
64:ff9b:1::                /96=False  /48=True
64:ff9b:1:a83f:8110::      /96=False  /48=True
64:ff9b:1:101:1:100::      /96=False  /48=True

Two things worth having on the record.

The prefixes are disjoint, so neither check can shadow the other and the order of the two branches is not load-bearing. That is worth knowing because it is the kind of thing a later refactor might reorder without thinking, and here it is safe.

More importantly, and this is the one I would put in a comment if it is not already: the decoded WireServer address is not caught by any generic rule.

168.63.129.16   is_private=False   is_link_local=False

So the explicit WireServer entry is not redundant with the private-range check, and anyone reviewing this who assumes "the private-address branch already covers Azure metadata" would be wrong. 169.254.169.254 decodes to link-local and would be caught either way; 168.63.129.16 would sail through. That asymmetry is the whole reason the WireServer rule has to exist separately, and it is invisible from reading the diff.

64:ff9b::808:808 decoding to 8.8.8.8 and staying allowed is the control I wanted to see, and it behaves.

No further concerns from me on the NAT64 half.

@Diwak4r

Copy link
Copy Markdown
Author

Thanks for measuring it at the head commit - that table is exactly the evidence this kind of change needs.

Both points are now on the record in the code: the WireServer comment block spells out that 168.63.129.16 has is_private=False and is_link_local=False (so no generic rule catches it, not even after NAT64 decoding via 64:ff9b::a83f:8110), and that the explicit entry is load-bearing rather than redundant with the range checks. The prefix-disjointness point is covered by the existing RFC citations on each prefix constant.

Comment-only commit: 96d71d7 (+6 lines).

@ErenAta16

Copy link
Copy Markdown

That comment is better than what I asked for.

Naming the wrong assumption outright, "do not remove it on the assumption that
the private-address branch already covers Azure metadata", is what makes it
survive a refactor. A comment that only states the fact tends to get deleted by
whoever is tidying up, precisely because the fact reads as redundant to someone
who already holds the wrong belief.

Nothing further from me on the NAT64 or WireServer half.

@Diwak4r

Copy link
Copy Markdown
Author

Thanks for the close read-through - glad the comment lands the way you wanted. I will leave the PR as is then, and watch for the workflow-approval gate on your side. Happy to rebase if main moves before then.

@Diwak4r
Diwakar Ray Yadav (Diwak4r) force-pushed the fix/ssrf-wireserver-metadata-ipv6 branch from 96d71d7 to eb61cee Compare August 21, 2026 17:14
@ErenAta16

Copy link
Copy Markdown

Taking you up on the rebase offer: main has moved since we last spoke. GitHub now
reports this branch as behind against a main whose most recent commit is 31
August, while the head here is still eb61ceed from 21 August. Nothing about the
change looks affected, it is just that a rebase now costs you one command and
after the next few merges it may not.

On the approval gate, that is not something I can move. I have no write access
here, so a first-time-contributor workflow run needs someone from the org to
approve it, and my read of your branch was static rather than a CI result.

The offer that is worth something from my side: if you rebase and the workflow
still does not run, say so on the thread and I will run the wrapper matrix against
your head locally and post the numbers. That is the part a maintainer would
otherwise have to take on trust, and it does not need CI to produce.

@Diwak4r
Diwakar Ray Yadav (Diwak4r) force-pushed the fix/ssrf-wireserver-metadata-ipv6 branch from eb61cee to e2be1e1 Compare September 1, 2026 08:50
@Diwak4r

Copy link
Copy Markdown
Author

Rebased onto latest main (e2be1e1). Ready for the test matrix run. Thank you!

@Diwak4r
Diwakar Ray Yadav (Diwak4r) force-pushed the fix/ssrf-wireserver-metadata-ipv6 branch 2 times, most recently from 19e16ca to e2be8ec Compare September 8, 2026 13:54

@ErenAta16 ErenAta16 (ErenAta16) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking up the offer I made: CI still has not run here, so here are the numbers
from running it locally instead.

First, confirming why you are still waiting. On e2be8eca three GitHub Actions
suites are action_required with zero runs each, and the only check that
executed is the Microsoft policy bot. That is the first-time-contributor workflow
gate, not a failure, and nothing you push will clear it. The branch has also gone
behind again since 8 September.

I ran a wrapper matrix against validate_server_url on your head, four addresses
by nine spellings, in both policy modes, with DNS stubbed out so nothing left the
machine.

Default mode is clean. All 36 combinations are refused, including every
obfuscated spelling of every address. Nothing to do there.

allow_private_network_access=True is where the interesting rows are, and the
plain WireServer case is exactly what you set out to fix:

168.63.129.16   plain            blocked      <- your change, working
168.63.129.16   ipv4-mapped v6   blocked      <- your change, working
168.63.129.16   6to4             blocked      <- your change, working
168.63.129.16   decimal          ALLOWED
168.63.129.16   hex              ALLOWED
168.63.129.16   octal            ALLOWED
168.63.129.16   trailing dot     ALLOWED

The four that get through do so before any of your logic runs.
_reject_cloud_metadata_host starts with ipaddress.ip_address(host) and returns
early on ValueError, and ip_address("2820128016") is a ValueError, as are the
hex, octal and trailing-dot forms. So the spelling never reaches
try_categorize_non_public_address to be categorised at all.

Whether that is reachable in practice depends on the client that later opens the
URL. On this Windows box getaddrinfo("2820128016") raises gaierror, so the
request would fail anyway; glibc's resolver generally accepts decimal, octal and
hex through inet_aton, so I would expect Linux to behave differently. I have not
tested that, and it is the part I would want a maintainer to decide on rather than
me.

The larger one is a gap between a comment and its implementation, and it is
older than your PR rather than caused by it. The call site says:

# Allowing access to a private network is not the same as allowing access to
# the host agent's cloud metadata endpoint, which is always blocked.
_reject_cloud_metadata_host(parsed_url)

but the function blocks one category only:

if blocked and category == _AZURE_WIRE_SERVER_CATEGORY:

Categorising the usual suspects shows what that leaves out:

168.63.129.16     blocked=True  category="Azure metadata (WireServer)"   <- rejected here
169.254.169.254   blocked=True  category="link-local"                    <- not rejected
100.100.100.200   blocked=True  category="carrier-grade NAT"             <- not rejected

169.254.169.254 is the IMDS address for AWS, GCP and Azure alike, and in this
mode it is allowed in all nine spellings including the plain one. It is correctly
categorised as non-public; it simply is not the category the gate compares
against. So the comment's "always blocked" is true of WireServer and not of the
endpoint most SSRF write-ups mean.

None of this makes your PR worse than what it replaces, and the default posture
is unaffected. It does mean the comment now promises more than the code delivers,
so either the gate should widen to every category
try_categorize_non_public_address marks as metadata, or the comment should say
WireServer specifically.

I have kept this on the thread because the default configuration is not affected,
this path is opt-in and experimental, and the PR is already a public discussion of
these exact addresses. If the maintainers read it differently, MSRC is the right
channel and I am happy for it to move there.

… 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.
…k mode

The call site documents that the host agent's cloud metadata endpoint "is
always blocked", but _reject_cloud_metadata_host only rejected the WireServer
category, so every other metadata endpoint passed when
allow_private_network_access=True:

    168.63.129.16     Azure metadata (WireServer)   rejected
    169.254.169.254   link-local                    allowed
    169.254.170.2     link-local                    allowed
    100.100.100.200   carrier-grade NAT             allowed
    fd00:ec2::254     private (IPv6 ULA)            allowed

169.254.169.254 is the IMDS address shared by AWS, GCP and Azure, so the gap
covered the endpoint most SSRF write-ups mean.

The category is not a usable signal here: "link-local", "carrier-grade NAT"
and "private (IPv6 ULA)" also cover ordinary private addresses that the
option exists to permit, and widening the gate to those categories would
remove the feature. Match the named metadata addresses instead, resolving
IPv4-mapped and NAT64-embedded IPv6 forms to the address they carry so
::ffff:169.254.169.254 and 64:ff9b::169.254.169.254 are caught as well.

Ordinary private addresses are unaffected: 10.0.0.5, 172.16.0.1, 192.168.1.50
and 100.64.0.1 (same category as the Alibaba Cloud address) still pass.

Adds 10 tests. Verified: 72 passed with this change; 6 failed, 66 passed
without it, the failures being exactly the six metadata cases above.

AI-assisted change, reviewed against the test output above.
@Diwak4r
Diwakar Ray Yadav (Diwak4r) force-pushed the fix/ssrf-wireserver-metadata-ipv6 branch from e2be8ec to 0c24fba Compare September 11, 2026 03:21
@Diwak4r

Copy link
Copy Markdown
Author

Rebased onto main — head is now 0c24fba58, so the branch is no longer behind. Taking you up on the offer: the workflow still has not run. The only checks on the new head are license/cla and add_label; the test jobs have not been scheduled, so there is still nothing to look at on CI.

While rebasing I went back through your metadata finding and acted on it, because you were right that the comment promised more than the code delivered.

_reject_cloud_metadata_host compared the category against _AZURE_WIRE_SERVER_CATEGORY only. Measured on the previous head:

address            blocked  category                     gate
168.63.129.16      True     Azure metadata (WireServer)  rejected
169.254.169.254    True     link-local                   allowed
169.254.170.2      True     link-local                   allowed
100.100.100.200    True     carrier-grade NAT            allowed
fd00:ec2::254      True     private (IPv6 ULA)           allowed

So 169.254.169.254 was reachable in private-network mode, exactly as you described.

I did not widen the gate to every category. Those categories also cover ordinary private addresses the option exists to permit — 100.64.0.1 is in the same category as the Alibaba Cloud address — so widening would remove the feature rather than narrow it. The named metadata addresses are matched instead, and IPv4-mapped / NAT64-embedded forms are resolved to the address they carry first, so ::ffff:169.254.169.254 and 64:ff9b::169.254.169.254 are caught as well. fd00:ec2::254 is in the set as the documented IPv6 form of IMDS.

Controls, so the change is pinned to addresses rather than to categories:

https://10.0.0.5/          allowed
https://172.16.0.1/        allowed
https://192.168.1.50/v1    allowed
https://100.64.0.1/        allowed   <- same category as 100.100.100.200

pytest tests/unit/connectors/openapi_plugin/test_server_url_validator.py — 72 passed. Reverted to the previous commit, 6 failed / 66 passed, the six being the metadata cases above.

One judgement call that is worth your eye rather than mine: 169.254.170.2 (ECS task metadata) is in the set. It is a credentials endpoint but it is not IMDS, so the set is slightly wider than "instance metadata" strictly means. Happy to drop it if you read that as scope creep.

The change is AI-assisted; the output above is from the run, not a description of one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: server_url_validator allows Azure WireServer (168.63.129.16) and IPv6-embedded IPv4 forms (NAT64/6to4/Teredo)

3 participants