Hardened defaults & the factory model¶
httpclient exists to answer one question well: what should a Go HTTP client look like
before you've configured anything? Its answer is "secure, bounded, and honest about
redirects", and it draws a deliberate line between the client factory (this module)
and the middleware (go/transit).
What NewClient sets for you¶
Calling NewClient() with no options still gives you a client that:
- Enforces a TLS 1.2 floor with curated cipher suites and curve preferences, from
go/tls's
DefaultConfig. Downgrade to an obsolete protocol is not reachable by accident. - Bounds its connection pool and timeouts —
MaxIdleConns, per-host limits, idle / TLS-handshake / response-header / dial timeouts — so a slow or hostile peer cannot exhaust resources or hang a request forever. - Refuses HTTPS→HTTP downgrades and caps redirects. The
CheckRedirectpolicy stops after the configured maximum and rejects any redirect that would drop fromhttpstohttp, closing a common credential/data-leak vector.
You opt out of these (via WithTLSConfig, WithTransport, WithMaxRedirects), never
in. The secure posture is the path of least resistance. The exact values are listed in
defaults and limits.
What the hardened defaults cost you¶
Hardening is not free, and two of the costs are invisible until they bite.
The client speaks HTTP/1.1 only. net/http switches off its automatic HTTP/2 upgrade
as soon as a transport carries a custom TLSClientConfig or DialContext, unless
ForceAttemptHTTP2 is set — and hardening the transport means setting both. A stock
http.DefaultClient will negotiate HTTP/2 to a server this one talks to over HTTP/1.1.
The curve list is explicit, so it does not follow Go's. Pinning CurvePreferences to
X25519 and P-256 means newer groups Go adds to its own default — the hybrid post-quantum
key exchanges, currently — are not offered.
Both are consequences of choosing explicit configuration over Go's evolving defaults: the posture is stable and auditable, at the price of not inheriting improvements. The workarounds for each are in what httpclient does not do.
Why the factory is separate from the middleware¶
The retry transport, circuit breaker, credential injection and request logging are not
in this module — they live in go/transit, and
NewClient merely wires them in through WithRetry / WithClientMiddleware. That split
is intentional:
- A transport concern is identical everywhere; a client is a policy. Retry-on-503 or host-pinned auth behave the same for every caller, so they belong in a shared, transport-neutral module. Connection limits, TLS trust and timeouts are choices a service makes, so they belong in a client factory the service owns.
- The middleware is reused by the server too.
go/transitis consumed by both clients and servers; keeping it separate means one tested implementation of each concern, not a client copy and a server copy. - Light graphs. Because the factory only pulls
go/tls+go/transit, a client-only consumer never links the server stack (controls, authn, gateway) or the gRPC SDK. Adepfootprint_test.goguard enforces this.
Why a downgrade cannot be laundered through an intermediate hop¶
The CheckRedirect policy is called by net/http before following each redirect. It
fails when the number of prior requests reaches the configured maximum, and — independently
— when the original request was https and the next hop is http. Because the check
reads via[0] (the first request) rather than the immediately-preceding hop, a chain
cannot launder a downgrade through an intermediate same-scheme redirect: https://a →
https://b → http://c is refused at the last step, because the comparison is still
against https://a.
The same reasoning governs the limit. via holds every request already made, so a maximum
of 10 means at most ten requests in the chain — the original plus nine hops. That is the
standard library's own arithmetic for its built-in limit of 10, kept deliberately so that
the number means what a reader of net/http expects.
Why supplying a CA pool replaces the system roots¶
WithCertPool sets RootCAs on the TLS configuration, and in crypto/tls that field is
the complete set of roots a client will verify against — nil means "use the host's
store", and any non-nil value means "these, and only these". There is no merge, here or in
the standard library.
That is a sharper edge than it looks: adding an internal CA to reach one internal service
silently removes trust in every public CA, and the failure appears later, on an unrelated
call, as x509: certificate signed by unknown authority. The module does not paper over
it by merging in the system pool, because a client that trusts only an internal CA is a
legitimate and tighter posture — the choice belongs to the caller, and
trust a private CA shows both.
Sensitive headers across redirects¶
Go's net/http strips only Authorization, Www-Authenticate, Cookie, Cookie2,
Proxy-Authorization and Proxy-Authenticate when a redirect crosses hosts — custom
credential headers are copied to every hop.
A client that authenticates with a header like PRIVATE-TOKEN or X-Api-Key will
happily forward it to wherever the response chain points: an object-storage 302, or an
open redirect on the trusted host aimed at an attacker.
WithSensitiveHeaders closes that gap by extending the stdlib's semantics to headers
you name:
On any redirect that leaves the origin (scheme, host or port) of the initial request, the named headers are removed before the hop is followed; same-origin redirects retain them. The comparison is deliberately strict — no default-port normalisation — so when in doubt the credential is stripped, and the hop proceeds unauthenticated (which is exactly what pre-signed object-storage URLs need). The option is opt-in: callers that intentionally follow authenticated redirects within one trust domain are unaffected unless they ask for it.
It protects only headers set on the request, because the redirect policy is the client's
layer and runs above the transport. A credential injected by middleware — WithBearerToken
and friends — is applied inside the transport on every hop, including redirect hops, so
stripping it in the policy would achieve nothing. Those defend themselves by pinning to a
host, which is the same guarantee reached from the other side of the seam;
keep a credential off a redirect
covers both cases as a task.
Relationship to go/transit¶
Think of it as two layers with one seam:
httpclient.NewClient → *http.Client
│ hardened transport (TLS, limits, timeouts, redirect policy)
└─ wraps → go/transit round-trippers (retry, breaker, auth, logging)
The factory owns the outer shell and the transport; transit owns everything that wraps a request on its way out. The transit middleware model covers the wrapping order.