[ANCHOR-1290]: SEP-10 client_domain SSRF: ANCHOR-1236 blocklist omits NAT64 (64:ff9b::/96) and 6to4 (2002::/16), re-reaching internal IPv4 on mainnet - #2007
Conversation
* ClientDomainHelper.isNonPublicAddress now unwraps NAT64 (64:ff9b::/96), 6to4 (2002::/16), and the deprecated IPv4-compatible IPv6 form before running the existing IPv4 checks against the real embedded address * blocks the NAT64/6to4 prefixes outright as defense in depth, even when the embedded address looks public * adds 0.0.0.0/8 coverage * adds SsrfBlocklistBypassTest covering the previously-missed ranges
There was a problem hiding this comment.
Pull request overview
Strengthens client_domain SSRF protection for embedded IPv4 transition addresses.
Changes:
- Blocks NAT64, 6to4, and
0.0.0.0/8. - Adds embedded IPv4 unwrapping and regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
ClientDomainHelper.java |
Extends the non-public address classifier. |
SsrfBlocklistBypassTest.kt |
Adds SSRF blocklist regression coverage. |
Suppressed comments (2)
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:38
- Every NAT64 case embeds a non-public IPv4, so the test still passes if the explicit prefix block is removed and only unwrap-and-recheck remains. Include a public payload to cover the stated requirement that
64:ff9b::/96is blocked outright.
for (h in listOf("64:ff9b::a9fe:a9fe", "64:ff9b::a00:1", "64:ff9b::7f00:1")) {
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:48
- Both 6to4 cases embed non-public IPv4 addresses, so they do not distinguish the explicit
2002::/16block from unwrap-and-recheck alone. Include a public embedded address to lock in the intended outright prefix rejection.
for (h in listOf("2002:a00:1::", "2002:7f00:1::")) {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
* isIpv4Compatible now requires bytes 10-11 to be zero, matching only the true ::/96 range instead of misclassifying most of ::/80 by its final 32 bits * add a real ::/96 literal and a regression case proving an ordinary IPv6 address is no longer misclassified
* isNonPublicAddress now also blocks 64:ff9b:1::/48 outright, closing the same embedded-IPv4 SSRF path on deployments using the local-use NAT64 prefix instead of the well-known 64:ff9b::/96 one * add a regression case for the /48 prefix
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:40
- The current cases all embed non-public IPv4 addresses, so they still pass if the unconditional NAT64-prefix check is accidentally removed and only
unwrapEmbeddedIPv4remains. Add a public payload such as8.8.8.8to verify the stated requirement that64:ff9b::/96is blocked outright.
for (h in listOf("64:ff9b::a9fe:a9fe", "64:ff9b::a00:1", "64:ff9b::7f00:1")) {
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:50
- Both cases embed non-public IPv4 addresses and therefore only prove that unwrapping works; they do not exercise the independent
2002::/16block. Include a public embedded address so a regression that removes the outright 6to4-prefix check is detected.
for (h in listOf("2002:a00:1::", "2002:7f00:1::")) {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:40
- These cases all embed addresses that the existing IPv4 predicates reject, so they still pass if the new outright NAT64-prefix check is removed. Add a case embedding a public IPv4 address (for example
64:ff9b::808:808) to cover the stated requirement that the prefix is blocked independently of its payload.
for (h in listOf("64:ff9b::a9fe:a9fe", "64:ff9b::a00:1", "64:ff9b::7f00:1")) {
core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt:50
- Both cases are rejected after unwrapping because their embedded IPv4 addresses are already non-public; they do not exercise the new requirement to reject all of
2002::/16. Include a public payload such as2002:808:808::so removal of the outright 6to4 check cannot leave this security behavior untested.
for (h in listOf("2002:a00:1::", "2002:7f00:1::")) {
Description
ANCHOR-1236 added
ClientDomainHelper.isNonPublicAddressas a private-network guard so the unauthenticatedGET /auth(and SEP-45 equivalent)client_domainfetch can't be pointed at an internal host. That guard blocks IPv4 private ranges, loopback, link-local,fc00::/7, and IPv4-mapped IPv6 (::ffff:a.b.c.d, auto-normalized toInet4Addressby the JDK), but never unwraps the two IPv6 transition ranges that carry a real IPv4 payload in their low bits: NAT64 (64:ff9b::/96, RFC 6052) and 6to4 (2002::/16, RFC 3056). Neither range matches any of the eight existing predicates, so aclient_domainresolving to64:ff9b::<internal-ipv4>or2002:<internal-ipv4>::passes both call sites that delegate to this classifier — the pre-fetch check and the connection-time DNS validator — and the platform connects through the operator's own NAT64/6to4 gateway to the embedded internal address.0.0.0.0/8had the same gap: only the exact0.0.0.0address was caught.Fixing this by only unwrapping and re-checking the embedded IPv4 isn't quite enough on its own: RFC 6052 leaves a NAT64 gateway's handling of a private embedded address implementation-defined, and a gateway could also forward to a public IP the platform's network trusts only via that internal path. So both prefixes are also blocked outright, in addition to the unwrap-and-recheck.
Changes
ClientDomainHelper.isNonPublicAddress: unwraps NAT64, 6to4, and the deprecated IPv4-compatible IPv6 form (::a.b.c.d) via a newunwrapEmbeddedIPv4before running the existing IPv4 predicates against the real embedded address; addsisThisNetworkfor0.0.0.0/8; blocks the NAT64/6to4 prefixes outright via newisNat64WellKnown/is6to4predicates, independent of what the embedded address resolves to.SsrfBlocklistBypassTest(new): runs the reporter's own reproduction directly againstClientDomainHelper.validateDomainNotPrivateNetwork— a control case confirming existing ranges stay blocked, and cases confirming the NAT64/6to4/0.0.0.0/8addresses are now blocked.Acceptance Criteria
client_domainresolving to64:ff9b::<internal-ipv4>(NAT64) is rejected by bothvalidateDomainNotPrivateNetworkandvalidatingDns.client_domainresolving to2002:<internal-ipv4>::(6to4) is rejected the same way.client_domainresolving to any0.0.0.0/8address is rejected.fc00::/7, CGNAT, IPv4-mapped) remain blocked.Context
HackerOne #3968541
Testing
./gradlew :core:test --tests "org.stellar.anchor.util.SsrfBlocklistBypassTest"./gradlew :core:test --tests "org.stellar.anchor.util.ClientDomainHelperTest"./gradlew :core:test(full module regression, no failures)Documentation
N/A
Known limitations
Doesn't change
Sep10Service.validateChallengeRequestClient's default of accepting anyclient_domainwhensep10.client_attribution_requiredis unset andsep10.client_allow_listis empty. The report frames an allow-list as a recommended defense-in-depth improvement, not a required part of this fix — it's a separate, broader policy change affecting every deployment's default config, not just attacker-controlled input, and deserves its own scoping.