Skip to content

Rework SDK HTTP transport: bind L2 to a maintained request client (SSRF-safe by default); scope zero-dependency policy to L1 #20

Description

@KonstantinMirin

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

  • Amend the dependency policy to state its scope is L1 (trust core), and that L2 may take vetted runtime dependencies.
  • Python: replace the hand-rolled OpenerDirector/urllib transport (sdk/python/ramp_sdk/resolvers/_http.py) with an httpx-bound L2 transport + connection-level SSRF pin.
  • TS: replace the raw http.request transport (sdk/ts/resolvers/http.ts) with an undici-bound transport + connect/lookup SSRF hook.
  • Go: confirm the existing net/http + Dialer.Control client already conforms; keep as reference.
  • Keep the shared SSRF address/scheme conformance corpora and the transport-wiring behavioral tests (non-2xx, redirect-refusal) green against the new transports.
  • Document, in the security/threat-model docs, egress control + IMDSv2 as recommended operator defense-in-depth (explicitly not a substitute for the in-code guard).

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions