Files
LocalAI/pkg/httpclient/client_test.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

133 lines
4.2 KiB
Go

package httpclient_test
import (
"crypto/tls"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHTTPClient(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "httpclient suite")
}
var _ = Describe("httpclient", func() {
Describe("New (default)", func() {
It("refuses to follow redirects and never reaches the redirect target", func() {
sinkHit := make(chan string, 1)
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sinkHit <- r.Header.Get("X-Api-Key")
w.WriteHeader(http.StatusOK)
}))
defer sink.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sink.URL, http.StatusFound)
}))
defer redirector.Close()
req, _ := http.NewRequest(http.MethodGet, redirector.URL, nil)
req.Header.Set("X-Api-Key", "secret")
_, err := httpclient.New().Do(req)
Expect(err).To(HaveOccurred(), "redirect must surface as an error")
Expect(errors.Is(err, httpclient.ErrRedirectBlocked)).To(BeTrue(), "error should wrap ErrRedirectBlocked")
Expect(sinkHit).NotTo(Receive(), "the redirect target must never be contacted")
})
It("sets no overall timeout (streaming-safe) by default", func() {
Expect(httpclient.New().Timeout).To(BeZero())
})
It("sets a TLS 1.2 floor on the default transport", func() {
c := httpclient.New()
t, ok := c.Transport.(*http.Transport)
Expect(ok).To(BeTrue())
Expect(t.TLSClientConfig).NotTo(BeNil())
Expect(t.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
})
})
Describe("NewWithTimeout", func() {
It("applies the overall timeout", func() {
Expect(httpclient.NewWithTimeout(5 * time.Second).Timeout).To(Equal(5 * time.Second))
})
})
Describe("WithFollowRedirects", func() {
It("follows same-host redirects keeping the credential header", func() {
got := make(chan string, 2)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/start" {
http.Redirect(w, r, srv.URL+"/end", http.StatusFound)
return
}
got <- r.Header.Get("X-Api-Key")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/start", nil)
req.Header.Set("X-Api-Key", "secret")
resp, err := httpclient.New(httpclient.WithFollowRedirects()).Do(req)
Expect(err).NotTo(HaveOccurred())
_ = resp.Body.Close()
Expect(<-got).To(Equal("secret"), "same-host redirect should preserve the header")
})
It("strips credential headers on a cross-host redirect", func() {
sinkKey := make(chan string, 1)
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sinkKey <- r.Header.Get("X-Api-Key")
w.WriteHeader(http.StatusOK)
}))
defer sink.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sink.URL, http.StatusFound)
}))
defer redirector.Close()
req, _ := http.NewRequest(http.MethodGet, redirector.URL, nil)
req.Header.Set("X-Api-Key", "secret")
resp, err := httpclient.New(httpclient.WithFollowRedirects()).Do(req)
Expect(err).NotTo(HaveOccurred())
_ = resp.Body.Close()
Expect(<-sinkKey).To(BeEmpty(), "x-api-key must be stripped crossing to a different host")
})
})
Describe("Harden", func() {
It("adds NoRedirect and a TLS floor to a bare client without clobbering existing config", func() {
c := httpclient.Harden(&http.Client{})
Expect(c.CheckRedirect).NotTo(BeNil())
t, ok := c.Transport.(*http.Transport)
Expect(ok).To(BeTrue())
Expect(t.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
})
It("returns nil for a nil client", func() {
Expect(httpclient.Harden(nil)).To(BeNil())
})
It("preserves a caller-supplied CheckRedirect", func() {
sentinel := errors.New("mine")
c := httpclient.Harden(&http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return sentinel },
})
Expect(c.CheckRedirect(nil, nil)).To(Equal(sentinel))
})
})
})