Files
LocalAI/pkg/httpclient/client.go
Richard Palethorpe 12d1f3a697 security(http): refuse redirects on outbound clients via hardened pkg/httpclient (#10087)
LocalAI's outbound HTTP clients used Go's default redirect policy, which
follows up to 10 redirects. On a cross-host redirect Go forwards custom
request headers — including credential headers such as Anthropic's
x-api-key — to the redirect target (Go strips Authorization, Cookie and
WWW-Authenticate cross-host, but NOT arbitrary custom headers). An
attacker able to elicit a redirect from an upstream (a hijacked or
spoofed upstream, DNS trickery, or a malicious upstream_url) then
harvests the operator's provider API key.

This was first reported against the cloud-proxy / MITM PII path
(GHSA-3mj3-57v2-4636); the same class affects every other outbound
client. Rather than patch each call site, add pkg/httpclient as the one
sanctioned constructor for outbound HTTP and route everything through it.

pkg/httpclient:
  - New(...)             refuses redirects, TLS 1.2 floor, no body
                         deadline (streaming/SSE safe)
  - NewWithTimeout(d)    simple request/response calls
  - WithFollowRedirects  opt-in following that still strips credential
                         headers on any cross-host hop; different
                         scheme/host/port == different origin, guarding
                         the curl CVE-2022-27774 port-confusion class
  - WithTransport(rt)    keep a custom transport (IP-pin, HTTP/2, a
                         credential-injecting RoundTripper)
  - HardenedTransport()  base transport with the TLS floor + bounded setup
  - Harden(c)            apply the policy to a library-supplied *http.Client
  - NoRedirect           the CheckRedirect policy; wraps ErrRedirectBlocked

Lint: a forbidigo rule flags http.DefaultClient and http.Get/Post/
PostForm/Head, pointing at pkg/httpclient (.golangci.yml,
.agents/coding-style.md). forbidigo cannot match the &http.Client{}
composite literal without also flagging legitimate *http.Client type
references, so that form is enforced by review.

Migrates every non-test outbound call site across core/, pkg/, cmd/, and
the Go backend (backend/go/cloud-proxy). Credential-bearing and
internal-RPC clients refuse redirects; download / CDN / registry clients
use WithFollowRedirects so they keep working while stripping secrets
cross-host. The only credential-bearing client that follows redirects is
the gated-download path (pkg/downloader/uri.go), which strips the token
on the cross-host hop to the CDN. Hardening this closes, in passing:
  - MCP remote-server bearer token leaking via a redirect (the
    RoundTripper re-injected Authorization on every hop)
  - agent multimedia/webhook clients leaking user-supplied auth headers
  - cors_proxy following redirects, bypassing its SSRF IP-pin
  - downloader's authorized read path leaking the token cross-host

Fixes: GHSA-3mj3-57v2-4636 (cloud-proxy leaks operator provider API key
(x-api-key) to attacker host on cross-host redirect)
Reported-by: tonghuaroot
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-30 12:04:10 +02:00

202 lines
7.7 KiB
Go

// Package httpclient provides hardened *http.Client constructors for all
// outbound HTTP traffic in LocalAI.
//
// Direct use of net/http's default client (http.DefaultClient, http.Get,
// http.Post, ...) or a bare http.Client{} is forbidden by lint (forbidigo).
// The reason is GHSA-3mj3-57v2-4636: the standard client follows up to 10
// redirects by default, and on a *cross-host* redirect Go forwards custom
// request headers — including credential headers such as Anthropic's
// x-api-key — to the redirect target. (Go strips Authorization, Cookie and
// WWW-Authenticate cross-host, but NOT arbitrary custom headers.) An attacker
// who can elicit a redirect from an upstream then harvests the credential.
//
// Every client built here refuses redirects by default (see NoRedirect). The
// rare caller that genuinely must follow redirects should opt in with
// WithFollowRedirects, which still strips credential headers on host change.
//
// Streaming note: New() intentionally sets NO client-level Timeout, because a
// global timeout also bounds the response body and would truncate long-lived
// SSE streams (chat completions can stream for minutes). Per-request deadlines
// belong on the request context. Use NewWithTimeout for simple, non-streaming
// request/response calls.
package httpclient
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const (
// Transport-level bounds. These cap connection setup, NOT the response
// body, so they are safe for streaming responses.
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
tlsHandshakeTimeout = 10 * time.Second
idleConnTimeout = 90 * time.Second
expectContinueTimeout = 1 * time.Second
maxIdleConns = 100
// maxRedirects bounds WithFollowRedirects chains (mirrors the net/http
// default) so an opt-in follower can't be spun forever by a redirect loop.
maxRedirects = 10
)
// sensitiveHeaders are credential-bearing request headers that must never be
// replayed to a different host on a redirect. Go already drops the first three
// cross-host; the rest are custom headers Go does not know about. Compared
// case-insensitively via http.Header canonicalisation.
var sensitiveHeaders = []string{
"Authorization",
"Www-Authenticate",
"Cookie",
"Proxy-Authorization",
"X-Api-Key", // Anthropic, and many OpenAI-compatible providers
"Api-Key", // Azure OpenAI
"X-Auth-Token", // common custom scheme
"X-Goog-Api-Key", // Google
}
// ErrRedirectBlocked is wrapped by the error NoRedirect returns, so callers can
// distinguish "the upstream tried to redirect us" from other transport errors
// via errors.Is.
var ErrRedirectBlocked = errors.New("httpclient: redirect blocked")
// NoRedirect is an http.Client.CheckRedirect policy that refuses to follow any
// redirect, surfacing it as an error instead. This is the default for clients
// built by New/NewWithTimeout. The error uses URL.Redacted() so userinfo in
// the target URL is not written to logs.
func NoRedirect(req *http.Request, _ []*http.Request) error {
return fmt.Errorf("%w: refusing to follow redirect to %s (set httpclient.WithFollowRedirects to opt in)", ErrRedirectBlocked, req.URL.Redacted())
}
// stripAuthOnRedirect follows redirects but deletes credential headers whenever
// the redirect crosses to a different host, closing the cross-host credential
// leak while still allowing same-host or non-authenticated redirect chains.
func stripAuthOnRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("httpclient: stopped after %d redirects", maxRedirects)
}
prev := via[len(via)-1]
if !sameOrigin(prev.URL, req.URL) {
for _, h := range sensitiveHeaders {
req.Header.Del(h)
}
}
return nil
}
// sameOrigin reports whether two URLs share scheme AND host (including port).
// Deliberately strict: a different port or scheme is treated as a different
// origin so credential headers are stripped. This avoids the curl
// CVE-2022-27774 class of bug where ports were ignored and credentials leaked
// to a different service on the same hostname.
func sameOrigin(a, b *url.URL) bool {
return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Host, b.Host)
}
// HardenedTransport returns a fresh *http.Transport with a TLS 1.2 floor and
// bounded connection setup. Callers that need to wrap or extend the transport
// (e.g. a credential-injecting RoundTripper) should base it on this rather than
// http.DefaultTransport so the TLS floor and timeouts are preserved.
func HardenedTransport() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: dialTimeout,
KeepAlive: dialKeepAlive,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: maxIdleConns,
IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: expectContinueTimeout,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
}
type options struct {
timeout time.Duration
transport http.RoundTripper
followRedirects bool
}
// Option configures a client built by New.
type Option func(*options)
// WithTimeout sets an overall client Timeout (covers the entire exchange
// including reading the body). Do NOT use this for streaming endpoints; prefer
// a per-request context deadline there. Equivalent to NewWithTimeout.
func WithTimeout(d time.Duration) Option { return func(o *options) { o.timeout = d } }
// WithTransport supplies a custom RoundTripper (e.g. an IP-pinned dialer or a
// credential-injecting wrapper). The caller is responsible for the transport's
// TLS configuration; base it on HardenedTransport to keep the TLS floor.
func WithTransport(rt http.RoundTripper) Option { return func(o *options) { o.transport = rt } }
// WithFollowRedirects opts into following redirects, while still stripping
// credential headers on any cross-host hop. Use only when an endpoint legitimately
// redirects (e.g. some download CDNs) and the request carries a secret.
func WithFollowRedirects() Option { return func(o *options) { o.followRedirects = true } }
// New returns a hardened *http.Client. By default it refuses redirects, sets a
// TLS 1.2 floor, bounds connection setup, and imposes no body deadline (safe
// for streaming). Apply Options to adjust.
func New(opts ...Option) *http.Client {
o := options{}
for _, fn := range opts {
fn(&o)
}
rt := o.transport
if rt == nil {
rt = HardenedTransport()
}
check := NoRedirect
if o.followRedirects {
check = stripAuthOnRedirect
}
return &http.Client{
Transport: rt,
Timeout: o.timeout, // zero == no overall deadline (streaming-safe)
CheckRedirect: check,
}
}
// NewWithTimeout returns a hardened client with an overall Timeout. Use for
// simple request/response calls; for streaming, use New with a context deadline.
func NewWithTimeout(timeout time.Duration, opts ...Option) *http.Client {
return New(append([]Option{WithTimeout(timeout)}, opts...)...)
}
// Harden applies the default hardening (refuse redirects, TLS 1.2 floor) to an
// existing client in place, for the cases where a third-party library hands us
// a *http.Client to configure rather than letting us construct one. It returns
// the same client for convenience. A nil client is left nil.
func Harden(c *http.Client) *http.Client {
if c == nil {
return nil
}
if c.CheckRedirect == nil {
c.CheckRedirect = NoRedirect
}
switch t := c.Transport.(type) {
case nil:
c.Transport = HardenedTransport()
case *http.Transport:
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
} else if t.TLSClientConfig.MinVersion == 0 {
t.TLSClientConfig.MinVersion = tls.VersionTLS12
}
}
return c
}