mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
A gallery model install hung for 94 minutes with zero bytes transferred, no
error, no retry and no abort, leaving a partial tree frozen at 18G. The last
log line was the download starting, then silence:
14:06:19 INFO Downloading url=".../LongCat-Video-Avatar-1.5/resolve/<rev>/base_model/diffusion_pytorch_model-000..."
The retry machinery from #10985 was working (two retries fired at 14:05:01 and
14:06:14); the third attempt simply never returned. The install never
completed, the model config was never written, and nothing surfaced the
failure.
The stall watchdog added earlier wraps the response *body*, so it only starts
guarding once downloadClient.Do() has returned. The transport had no
ResponseHeaderTimeout, so a peer that completes the dial and TLS handshake,
reads the request, and then never sends a status line parks Do() for the
process lifetime. IdleConnTimeout governs pooled idle connections, not an
in-flight request. Both the body request and the HEAD that probes for Range
support were unguarded.
Bound the header wait at the transport, not the client: a client-level Timeout
would also bound the body and truncate multi-tens-of-GB downloads. The knob is
opt-in (WithResponseHeaderTimeout) rather than a default in HardenedTransport,
because a streaming endpoint may legitimately withhold headers until it has
something to say, and capping that would break the streaming clients that share
this constructor.
Also fix a classification trap this exposed: net/http reports a
ResponseHeaderTimeout as an error satisfying errors.Is(err,
context.DeadlineExceeded), which IsRetryable read as "the caller gave up" and
refused to retry. An explicit transient marking now outranks the cancellation
sentinels; a caller who genuinely gave up is still caught by the ctx.Err()
check. The resume probe's error is likewise marked transient, so a momentarily
wedged origin no longer turns a resumable download into a hard install failure.
Third defect found in this download path, after #10985 (read vs write errors
conflated) and #11026 (hash verification emitted no progress and an expired
deadline returned success).
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
225 lines
8.9 KiB
Go
225 lines
8.9 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
|
|
}
|
|
|
|
// 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 } }
|
|
|
|
// 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()
|
|
}
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|