Summary
The SDK's HTTP transport for WBA directory resolution is hand-rolled in each language, and that layer has produced a recurring stream of security findings (a fresh SSRF-guard bypass or transport-wiring bug almost every review round). The root cause is a policy scope error: the zero-dependency policy was applied to L2 (I/O) as well as L1 (the trust core), which forced us to reimplement an HTTP client — badly — in order to bolt an SSRF check onto it. This issue proposes reworking the transport so L2 binds to a maintained HTTP client with an SSRF check at the connection seam, safe by default, while L1 stays pure and dependency-free.
Background: the attack we're guarding
A caller supplies a Signature-Agent header (a host it controls). To verify the request's signature we must fetch that host's public-key directory (/.well-known/http-message-signatures-directory) before the ed25519 check — we can't verify until we've fetched the key. So an unauthenticated caller controls a URL the server fetches from inside its own network: classic pre-auth SSRF (e.g. Signature-Agent: http://169.254.169.254/… to reach cloud metadata). The fetch happening at all is the win condition, regardless of whether verification later fails.
Why the current approach keeps failing
An application-layer SSRF denylist enumerates an open-ended set (reserved ranges × schemes × redirect tricks × address encodings). Every round found another spelling of "internal":
- CGNAT
100.64.0.0/10 and 0.0.0.0/8 missed by stdlib IsPrivate/is_private
- IPv6 zone-id bypass (
fe80::1%eth0) skipping netip.Prefix.Contains
302 → ftp:// slipping through a redirect into an unguarded handler
- v4-mapped / NAT64 / 6to4 / v4-compatible forms
- a non-2xx crash from hand-assembling
urllib's handler chain (dropped HTTPDefaultErrorHandler)
These aren't sloppiness — they're the nature of the denylist, made worse by the fact that we abandoned the maintained HTTP client (status/redirect/1xx/decompression state machine) and now own all of it, in three languages.
Why not "make egress control the primary defense"
Egress control (proxy / network policy / IMDSv2) is the right primary defense for a service you operate. It is not sufficient for an OSS SDK: the artifact runs in deployments we don't control or audit. "Safe only if the operator also deployed a proxy" is not safe-by-default — the reachable pre-auth SSRF is present in the code we ship, so it is a vulnerability against this repo, and "the operator should have configured egress" is not a defense to a report. The correctness must live in the shipped code. Egress remains valuable as recommended operator hardening (defense-in-depth), just not load-bearing for our correctness.
Proposed design
Scope the zero-dependency policy to L1 only.
- L1 — pure, zero-dep, no I/O. Crypto, JCS canonicalization, signature-base, protocol types, validation. This is the trust core; dependency-freedom genuinely matters here (auditability, no transitive supply chain). Keep it hermetic.
- L2 — opinionated, may depend. Composition + I/O. Ship a transport component bound to a recommended, maintained HTTP client, pre-configured for SSRF safety, documented (library + config). The injection seam stays open for consumers who bring their own client, but the default L2 transport is the safe, bound one — so out-of-the-box is safe and not hand-rolled.
The binding is the same shape in every language: a maintained client owns the response/redirect/1xx machine; we add one SSRF check at the connection seam.
| SDK |
Client |
SSRF seam |
| Go |
net/http (already) |
Dialer.Control (already) — this is the reference; it had the fewest problems precisely because it never left the maintained client |
| Python |
httpx |
custom transport / connection-level pin |
| TS |
undici (official Node client) |
custom connect/lookup |
Consequences:
- Status/redirect/1xx/decompression correctness comes from the library.
if status != 200 becomes an explicit domain decision on top of a correct client, not a substitute for one.
- We stop maintaining a CIDR table and a redirect state machine by hand.
- The recurring per-language bypasses collapse to "configure the library's check," which is one small, testable surface.
Packaging: the safe transport is the default of the L2 package (mandatory dep there); L1 remains a separate dependency-free package. A "core" + "batteries-included" split.
Honest caveat
A maintained SSRF check is still a denylist that chases ranges — binding to a library removes our fragility, not the category's. So we should still recommend egress control to operators as defense-in-depth. Ownership split: code = our safe-by-default correctness; egress = the operator's optional hardening.
Scope of work
References
The recurring findings that motivated this all live on the SDK-extraction PR (#17): SEC-NEW-1 (CGNAT/0.0.0.0/8), SEC3-NEW-1 (IPv6 zone-id), SEC3-NEW-2 (FTP redirect), and the non-2xx opener crash. Each was fixed in place; this issue proposes removing the class rather than continuing to patch it.
Summary
The SDK's HTTP transport for WBA directory resolution is hand-rolled in each language, and that layer has produced a recurring stream of security findings (a fresh SSRF-guard bypass or transport-wiring bug almost every review round). The root cause is a policy scope error: the zero-dependency policy was applied to L2 (I/O) as well as L1 (the trust core), which forced us to reimplement an HTTP client — badly — in order to bolt an SSRF check onto it. This issue proposes reworking the transport so L2 binds to a maintained HTTP client with an SSRF check at the connection seam, safe by default, while L1 stays pure and dependency-free.
Background: the attack we're guarding
A caller supplies a
Signature-Agentheader (a host it controls). To verify the request's signature we must fetch that host's public-key directory (/.well-known/http-message-signatures-directory) before the ed25519 check — we can't verify until we've fetched the key. So an unauthenticated caller controls a URL the server fetches from inside its own network: classic pre-auth SSRF (e.g.Signature-Agent: http://169.254.169.254/…to reach cloud metadata). The fetch happening at all is the win condition, regardless of whether verification later fails.Why the current approach keeps failing
An application-layer SSRF denylist enumerates an open-ended set (reserved ranges × schemes × redirect tricks × address encodings). Every round found another spelling of "internal":
100.64.0.0/10and0.0.0.0/8missed by stdlibIsPrivate/is_privatefe80::1%eth0) skippingnetip.Prefix.Contains302 → ftp://slipping through a redirect into an unguarded handlerurllib's handler chain (droppedHTTPDefaultErrorHandler)These aren't sloppiness — they're the nature of the denylist, made worse by the fact that we abandoned the maintained HTTP client (status/redirect/1xx/decompression state machine) and now own all of it, in three languages.
Why not "make egress control the primary defense"
Egress control (proxy / network policy / IMDSv2) is the right primary defense for a service you operate. It is not sufficient for an OSS SDK: the artifact runs in deployments we don't control or audit. "Safe only if the operator also deployed a proxy" is not safe-by-default — the reachable pre-auth SSRF is present in the code we ship, so it is a vulnerability against this repo, and "the operator should have configured egress" is not a defense to a report. The correctness must live in the shipped code. Egress remains valuable as recommended operator hardening (defense-in-depth), just not load-bearing for our correctness.
Proposed design
Scope the zero-dependency policy to L1 only.
The binding is the same shape in every language: a maintained client owns the response/redirect/1xx machine; we add one SSRF check at the connection seam.
net/http(already)Dialer.Control(already) — this is the reference; it had the fewest problems precisely because it never left the maintained clienthttpxundici(official Node client)connect/lookupConsequences:
if status != 200becomes an explicit domain decision on top of a correct client, not a substitute for one.Packaging: the safe transport is the default of the L2 package (mandatory dep there); L1 remains a separate dependency-free package. A "core" + "batteries-included" split.
Honest caveat
A maintained SSRF check is still a denylist that chases ranges — binding to a library removes our fragility, not the category's. So we should still recommend egress control to operators as defense-in-depth. Ownership split: code = our safe-by-default correctness; egress = the operator's optional hardening.
Scope of work
OpenerDirector/urllibtransport (sdk/python/ramp_sdk/resolvers/_http.py) with anhttpx-bound L2 transport + connection-level SSRF pin.http.requesttransport (sdk/ts/resolvers/http.ts) with anundici-bound transport +connect/lookupSSRF hook.net/http+Dialer.Controlclient already conforms; keep as reference.References
The recurring findings that motivated this all live on the SDK-extraction PR (#17): SEC-NEW-1 (CGNAT/0.0.0.0/8), SEC3-NEW-1 (IPv6 zone-id), SEC3-NEW-2 (FTP redirect), and the non-2xx opener crash. Each was fixed in place; this issue proposes removing the class rather than continuing to patch it.