mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(httpclient): validate redirect targets and pin dialed addresses for user URLs Clients that fetch user-supplied URLs followed redirects without checking the target, so a public host could send them to a loopback, link-local or private address. The host was also resolved twice, once to validate it and once to connect, so a changing DNS answer could bypass the check. Add httpclient.WithExternalOnly. It validates every redirect hop against the external URL policy and, for the default transport, resolves the host, refuses it if any address is non-public, and dials the validated address. Environment proxies are not used for such clients. Move the policy (ValidateExternalURL, IsPublicIP) into pkg/httpclient so it can be used without an import cycle; pkg/utils keeps delegating to it. IsPublicIP now also refuses 100.64.0.0/10, 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4, multicast, and NAT64 addresses that wrap a non-public IPv4 address. Use the option for the base64, image, audio and video downloads, and drop the video client's own per-hop check. Assisted-by: Claude Code:claude-haiku-5-5 Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
259 lines
10 KiB
Go
259 lines
10 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
|
|
responseHeaderTimeout time.Duration
|
|
transport http.RoundTripper
|
|
followRedirects bool
|
|
externalOnly 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 } }
|
|
|
|
// WithResponseHeaderTimeout bounds how long the transport waits for a
|
|
// response's headers after the request has been written. Unlike WithTimeout it
|
|
// does NOT bound the response body, so it is safe for long transfers: it only
|
|
// catches a peer that accepts the connection and then never answers, which
|
|
// would otherwise park Do() for the process lifetime.
|
|
//
|
|
// Opt-in rather than a default in HardenedTransport, because a streaming
|
|
// endpoint may legitimately withhold headers until it has something to say (a
|
|
// queued or slow-to-first-token completion), and capping that would break
|
|
// streaming clients. Use it for request/response traffic and bulk downloads.
|
|
func WithResponseHeaderTimeout(d time.Duration) Option {
|
|
return func(o *options) { o.responseHeaderTimeout = d }
|
|
}
|
|
|
|
// 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 } }
|
|
|
|
// WithExternalOnly restricts a client to hosts that pass the external URL
|
|
// policy (see IsPublicIP). Use it for every client that fetches a URL supplied
|
|
// by a user. It has two effects:
|
|
//
|
|
// - Each redirect hop is validated like the original URL, so a public host
|
|
// cannot bounce the client to a loopback, link-local or private target.
|
|
// It only matters together with WithFollowRedirects.
|
|
// - Unless WithTransport is given, the transport resolves the host itself,
|
|
// rejects it if any resolved address is not public, and dials one of the
|
|
// validated addresses, so a second DNS lookup cannot return a different
|
|
// answer. Environment proxies are not used for such a client, since the
|
|
// proxy address would be what gets validated and dialled.
|
|
//
|
|
// A caller-supplied transport is left untouched and must enforce the policy
|
|
// itself.
|
|
func WithExternalOnly() Option { return func(o *options) { o.externalOnly = 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 {
|
|
t := HardenedTransport()
|
|
if o.externalOnly {
|
|
t.Proxy = nil
|
|
t.DialContext = validatedDialContext(&net.Dialer{
|
|
Timeout: dialTimeout,
|
|
KeepAlive: dialKeepAlive,
|
|
})
|
|
}
|
|
rt = t
|
|
}
|
|
// Only an *http.Transport carries the knob; a caller-supplied wrapper
|
|
// (bearer-token RoundTripper, IP-pinned dialer) is left untouched rather
|
|
// than silently ignored-with-a-lie, so it must set the field itself.
|
|
if o.responseHeaderTimeout > 0 {
|
|
if t, ok := rt.(*http.Transport); ok {
|
|
t.ResponseHeaderTimeout = o.responseHeaderTimeout
|
|
}
|
|
}
|
|
|
|
check := NoRedirect
|
|
if o.followRedirects {
|
|
check = stripAuthOnRedirect
|
|
if o.externalOnly {
|
|
check = func(req *http.Request, via []*http.Request) error {
|
|
if err := validateRedirectTarget(req); err != nil {
|
|
return err
|
|
}
|
|
return stripAuthOnRedirect(req, via)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|