Skip to content

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 timeoutsMaxIdleConns, 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 CheckRedirect policy stops after the configured maximum and rejects any redirect that would drop from https to http, 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/transit is 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. A depfootprint_test.go guard 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://ahttps://bhttp://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:

client := httpclient.NewClient(
    httpclient.WithSensitiveHeaders("PRIVATE-TOKEN"),
)

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.