fix(dotnet): close SSRF gaps in WebhookApprover endpoint validation - #1
Conversation
|
Welcome to the Agent Governance Toolkit! Thanks for your first pull request. |
🤖 AI Agent: contributor-guide — 🌟 What You Did WellHi @carloshvp! 👋 Welcome to the Agent Governance Toolkit community, and thank you for your first contribution! 🎉 We're thrilled to have you here, and you've tackled an important issue with a thoughtful and comprehensive approach. Let's dive into your pull request together! 🌟 What You Did Well
🔍 Suggestions for ImprovementWhile your contribution is excellent, there are a few areas where we can align it more closely with the project's conventions and best practices:
📚 Helpful ResourcesHere are some resources to help you navigate the contribution process:
🚀 Next Steps
Thank you again for your contribution, @carloshvp! Your work is a fantastic example of how thoughtful, well-documented code can make a big impact. We're excited to have you as part of the community and look forward to collaborating with you on this project. 😊 |
dad0fc9 to
802290b
Compare
ce1a193 to
75a3fe3
Compare
The base validator only checked a small named blocklist and IPv4 link-local addresses, and only ran that check against literal-IP endpoints. It missed loopback, RFC 1918 private ranges, and many other address classes an approval webhook must never reach, and a hostname bypassed validation entirely because DNS resolution happened later, inside HttpClient, outside the guard. This replaces the validator with a resolve-once, connect-time guard. When WebhookApprover owns its HttpClient it uses a SocketsHttpHandler.ConnectCallback that resolves the host, validates the resolved address, and opens the socket directly to that validated address, so there is no second independent resolution between the address that was checked and the address that was connected to. That closes the DNS-rebinding gap a constructor-time-only check leaves open. The constructor still runs the same check eagerly against literal-IP endpoints so a blocked target fails fast. The blocked-address set now covers, on IPv4: loopback, 0.0.0.0, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 link-local (including the cloud metadata address 169.254.169.254), 100.64.0.0/10 CGNAT (which carries the Alibaba Cloud metadata address 100.100.100.200), 198.18.0.0/15 benchmarking, 224.0.0.0/4 multicast, and 240.0.0.0/4 reserved space (including the 255.255.255.255 broadcast address). On IPv6: ::, ::1, fe80::/10 link-local, fc00::/7 unique-local, fec0::/10 deprecated site-local, and the 64:ff9b::/96 NAT64 well-known prefix. IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses are unwrapped to their IPv4 form before the range check, so neither mapping can smuggle a blocked IPv4 address past the IPv6 branch. Range checks use AddressFamily branches and byte comparisons, never string parsing of addresses. Ports are not restricted, matching the base validator, which sets no port convention. Redirect refusal from the base validator is retained (AllowAutoRedirect is false on the owned handler), so a remote endpoint cannot bounce the request onto another target, and the connect-time guard would revalidate any redirect target anyway. The base's stricter approve-response identity check is retained unchanged. WebhookApproverSecurityTests adds public-surface regression cases for each newly blocked range and confirms addresses just outside each range and legitimate public addresses still pass. The base redirect test was converted to a network-free handler assertion because the hardened guard now rejects any loopback target, which makes a live local redirect server unreachable by design. Addresses microsoft#3369. Signed-off-by: AlgoVoi <chopmob@gmail.com>
75a3fe3 to
11c87b1
Compare
|
Rebased onto the current dotnet-approval-chain-parity tip to clear the merge conflict from the base rewrite. Our redundant parity commit was dropped (base already carries parity), so the branch is now a single commit and the diff against base is exactly the WebhookApprover hardening plus its tests. Base's own additions (approval lifecycle hardening, redirect refusal, stricter approve-response identity check) are preserved, not reverted. The validator is now a resolve-once, connect-time guard: when WebhookApprover owns its HttpClient it resolves the host and connects the socket directly to the validated address via SocketsHttpHandler.ConnectCallback, closing the DNS-rebinding gap a constructor-only check leaves open. AllowAutoRedirect stays false, so a remote endpoint cannot bounce the request onto another target. The blocked-address set was expanded to cover the ranges the earlier check missed: on IPv4, loopback, 0.0.0.0, RFC 1918, 169.254/16 (incl. 169.254.169.254 metadata), 100.64.0.0/10 CGNAT (incl. Alibaba 100.100.100.200), 198.18.0.0/15, 224.0.0.0/4 multicast, and 240.0.0.0/4 reserved (incl. 255.255.255.255); on IPv6, link-local, fc00::/7 unique-local, fec0::/10 site-local, and the 64:ff9b::/96 NAT64 prefix, with IPv4-mapped and IPv4-compatible addresses unwrapped before the check. Checks use AddressFamily branches and byte comparisons, no string parsing. New xUnit cases assert each newly blocked range plus boundary and public addresses. One base test that pointed the owned client at a loopback server was converted to a network-free handler assertion, since the guard now rejects loopback targets by design. Full suite is green (843 tests). |
Summary
Addresses microsoft#3369, filed against this branch during review:
WebhookApprover.ValidateEndpointis advertised as an SSRF control but its blocklist is partial.Problem
ValidateEndpointonly checked a small named blocklist (cloud metadata hosts) and IPv4 link-local addresses. It did not block:127.0.0.0/8,::1)10.0.0.0/8,172.16.0.0/12,192.168.0.0/16)fc00::/7)0.0.0.0It also only ran against literal IP endpoints. A hostname endpoint bypassed validation entirely, since DNS resolution happened later, inside
HttpClient.SendAsync, outside this check.Fix
IsBlockedAddress) to cover loopback, the missing private ranges, IPv6 unique-local, and0.0.0.0, alongside the existing link-local and named-host checks.::ffff:a.b.c.d) before checking, so a mapped address cannot smuggle a blocked IPv4 address past the IPv6 branch.SocketsHttpHandler.ConnectCallbackthat resolves the host and validates the resolved address at actual connect time, used when this class creates its ownHttpClient(the common path,httpClientargument omitted). This closes the DNS rebinding gap a constructor-time-only check leaves open: there is no second, independent resolution between "the address that was checked" and "the address that was connected to", because they are the same operation. Verified empirically that this also covers redirects:ConnectCallbackfires once per connection the handler opens, including a connection needed to follow a redirect to a different host.HttpClient, this class does not control its transport and cannot enforce the connect-time guard; documented on the class per the issue's acceptance criterion 4, rather than silently overclaiming coverage.Scope note
This repository's
AgentGovernanceassembly is strong-name signed, andInternalsVisibleTois not set up for the test project (existing doc comments elsewhere in the codebase discuss why that is friction under strong-naming). Following the existing convention, the new validation logic isprivateand exercised entirely through the publicWebhookApproverconstructor in the added tests, rather than unit-tested directly.Tests
WebhookApproverSecurityTests.cs, 17 cases through the public constructor:Validation
Isolated .NET 8 SDK build, run twice independently (SHA-256-verified against a fresh fetch of this branch both times):
0.0.0.0); the 8 already-covered cases still pass. Confirms the tests reproduce a real gap, not a false positive.HttpListeners confirmedConnectCallbackfires per connection, including on a redirect to a different host:port, so the connect-time guard is not limited to the initial request.Happy to adjust the fix shape, split it, or fold it directly into microsoft#3363 if you'd rather carry it there instead of as a follow-up PR.