Skip to content

Commit 12d1f3a

Browse files
authored
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>
1 parent a7cad70 commit 12d1f3a

28 files changed

Lines changed: 583 additions & 86 deletions

File tree

.agents/coding-style.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ Do not mix styles within a package. If you are extending tests in a package that
5050

5151
This is enforced by `golangci-lint` via the `forbidigo` linter (see `.golangci.yml`); calls like `t.Errorf` / `t.Fatalf` / `t.Run` / `t.Skip` / `t.Logf` are flagged. Run `make lint` locally before submitting; the same check runs in CI (`.github/workflows/lint.yml`).
5252

53+
## Outbound HTTP
54+
55+
All outbound HTTP must go through `github.com/mudler/LocalAI/pkg/httpclient` rather than the standard library's default client. Use `httpclient.New(...)` (no body deadline — safe for streaming/SSE) or `httpclient.NewWithTimeout(d, ...)` (simple request/response). Both **refuse redirects by default** and set a TLS 1.2 floor.
56+
57+
The reason is GHSA-3mj3-57v2-4636: the std default client follows redirects, and on a *cross-host* redirect Go forwards custom credential headers (e.g. Anthropic's `x-api-key`) to the redirect target, leaking the secret. `httpclient` fails closed instead.
58+
59+
- Need to follow redirects (download CDNs, registry blobs, GitHub asset URLs)? Pass `httpclient.WithFollowRedirects()` — it still strips credential headers on any cross-host hop.
60+
- Have a custom transport (IP-pinned dialer, HTTP/2 tuning, a credential-injecting `RoundTripper`)? Pass `httpclient.WithTransport(rt)`, basing the transport on `httpclient.HardenedTransport()` to keep the TLS floor. Handed a `*http.Client` by a library? `httpclient.Harden(c)` applies the policy in place.
61+
62+
This is enforced by `forbidigo` (see `.golangci.yml`): `http.DefaultClient` and `http.Get`/`Post`/`PostForm`/`Head` are flagged. The `&http.Client{}` composite literal can't be matched precisely by forbidigo without also flagging legitimate `*http.Client` type references, so that form is caught by review — don't construct raw clients.
63+
5364
## Documentation
5465

5566
The project documentation is located in `docs/content`. When adding new features or changing existing functionality, it is crucial to update the documentation to reflect these changes. This helps users understand how to use the new capabilities and ensures the documentation stays relevant.

.golangci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ linters:
5656
# are exempt — see linters.exclusions.rules below.
5757
- pattern: '^os\.(Getenv|LookupEnv|Environ)$'
5858
msg: 'Plumb config through ApplicationConfig (or the relevant CLI struct) instead of reading env directly. CLI entry points (core/cli/) bind env vars via kong''s `env:` tag — that is the only sanctioned env→struct boundary. See .agents/coding-style.md.'
59+
# Outbound HTTP must go through pkg/httpclient, which refuses redirects
60+
# by default and sets a TLS floor. The std-library default client and
61+
# the http.Get/Post/... convenience helpers follow redirects (up to 10)
62+
# and, on a cross-host redirect, forward custom credential headers such
63+
# as Anthropic's x-api-key to the redirect target — leaking the secret
64+
# (GHSA-3mj3-57v2-4636). forbidigo can't precisely match the
65+
# `&http.Client{}` composite literal without also flagging legitimate
66+
# `*http.Client` type references, so that form is enforced by
67+
# convention + review; these two patterns catch the implicit-default
68+
# client, which is the common footgun.
69+
- pattern: '^http\.DefaultClient$'
70+
msg: 'Use pkg/httpclient (httpclient.New / NewWithTimeout) instead of http.DefaultClient — the std client follows redirects and leaks credential headers cross-host (GHSA-3mj3-57v2-4636). See .agents/coding-style.md.'
71+
- pattern: '^http\.(Get|Post|PostForm|Head)$'
72+
msg: 'Use pkg/httpclient (httpclient.New / NewWithTimeout) instead of http.Get/Post/PostForm/Head — these use http.DefaultClient, which follows redirects and leaks credential headers cross-host (GHSA-3mj3-57v2-4636). See .agents/coding-style.md.'
5973
exclusions:
6074
paths:
6175
# Upstream whisper.cpp source tree fetched by the whisper backend Makefile.
@@ -95,3 +109,18 @@ linters:
95109
- path: _test\.go$
96110
text: 'os\.(Getenv|LookupEnv|Environ)'
97111
linters: [forbidigo]
112+
# pkg/httpclient is the sanctioned home for outbound HTTP clients; it
113+
# necessarily references net/http directly.
114+
- path: ^pkg/httpclient/
115+
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
116+
linters: [forbidigo]
117+
# Tests drive local httptest servers where redirect/TLS hardening is
118+
# irrelevant; the std client is fine there.
119+
- path: _test\.go$
120+
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
121+
linters: [forbidigo]
122+
# Vendored upstream whisper.cpp Go bindings are a separate module and
123+
# cannot import pkg/httpclient.
124+
- path: ^backend/go/whisper/sources/
125+
text: 'http\.(DefaultClient|Get|Post|PostForm|Head)'
126+
linters: [forbidigo]

backend/go/cloud-proxy/passthrough_edge_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,61 @@ var _ = Describe("Forward", func() {
192192
Expect(<-gotAuth).To(Equal("Bearer sk-real"), "caller-supplied Basic header must be replaced")
193193
})
194194

195+
It("refuses to follow upstream redirects and never leaks the key to the redirect target", func() {
196+
// A 3xx from the configured upstream means misconfiguration or a
197+
// hijacked/spoofed host. Following it would replay the request —
198+
// and the injected API key — to the Location host. Anthropic's
199+
// x-api-key is NOT stripped by Go on cross-host redirects, so this
200+
// would be a credential leak. The proxy must refuse the redirect.
201+
sinkHit := make(chan string, 1)
202+
sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
203+
sinkHit <- r.Header.Get("x-api-key")
204+
w.WriteHeader(http.StatusOK)
205+
}))
206+
defer sink.Close()
207+
208+
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
209+
http.Redirect(w, r, sink.URL, http.StatusFound)
210+
}))
211+
defer redirector.Close()
212+
213+
GinkgoT().Setenv("CLOUD_PROXY_REDIRECT_KEY", "ant-secret")
214+
215+
cp := NewCloudProxy()
216+
Expect(cp.Load(&pb.ModelOptions{
217+
Proxy: &pb.ProxyOptions{
218+
UpstreamUrl: redirector.URL,
219+
Mode: modePassthrough,
220+
Provider: providerAnthropic,
221+
ApiKeyEnv: "CLOUD_PROXY_REDIRECT_KEY",
222+
},
223+
})).To(Succeed())
224+
225+
addr := "test://forward-no-redirect"
226+
grpc.Provide(addr, cp)
227+
c := grpc.NewClient(addr, true, nil, false)
228+
stream, err := c.Forward(context.Background())
229+
Expect(err).NotTo(HaveOccurred())
230+
Expect(stream.Send(&pb.ForwardRequest{
231+
Path: "/v1/messages",
232+
Method: "POST",
233+
})).To(Succeed())
234+
Expect(stream.CloseSend()).To(Succeed())
235+
236+
// Drain the stream; a refused redirect surfaces as a non-EOF error.
237+
var streamErr error
238+
for {
239+
if _, err := stream.Recv(); err != nil {
240+
if !errors.Is(err, io.EOF) {
241+
streamErr = err
242+
}
243+
break
244+
}
245+
}
246+
Expect(streamErr).To(HaveOccurred(), "refused redirect must surface as an error")
247+
Expect(sinkHit).NotTo(Receive(), "the redirect target must never be contacted")
248+
})
249+
195250
It("handles concurrent calls without interference", func() {
196251
// CloudProxy explicitly omits base.SingleThread — independent
197252
// Forward streams must not block each other or leak state.

backend/go/cloud-proxy/proxy.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import (
1111
"strings"
1212
"sync/atomic"
1313

14+
"github.com/mudler/xlog"
15+
1416
"github.com/mudler/LocalAI/pkg/grpc/base"
1517
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
16-
"github.com/mudler/xlog"
18+
"github.com/mudler/LocalAI/pkg/httpclient"
1719
)
1820

1921
// Mirror of core/config.Proxy{Mode,Provider}* — backends don't
@@ -48,10 +50,15 @@ type proxyConfig struct {
4850
}
4951

5052
func NewCloudProxy() *CloudProxy {
51-
// No Client-level Timeout — that would bound streaming SSE
52-
// responses too, which can legitimately last minutes. Per-request
53-
// deadlines come from the gRPC stream context.
54-
return &CloudProxy{client: &http.Client{}}
53+
// httpclient.New refuses redirects outright: the proxy talks to a
54+
// single configured upstream API (OpenAI/Anthropic/...) that answers
55+
// directly, so a 3xx means misconfiguration, a hijacked upstream, or
56+
// DNS trickery — never normal operation. Following it would replay the
57+
// request, including the operator's x-api-key (which Go does NOT strip
58+
// on cross-host redirects), to an unvetted host and leak the key
59+
// (GHSA-3mj3-57v2-4636). It also imposes no body deadline, so streaming
60+
// SSE responses that legitimately last minutes are not truncated.
61+
return &CloudProxy{client: httpclient.New()}
5562
}
5663

5764
func (c *CloudProxy) Load(opts *pb.ModelOptions) error {
@@ -426,4 +433,3 @@ func isHopByHopHeader(name string) bool {
426433
}
427434
return false
428435
}
429-

cmd/launcher/internal/release_manager.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"time"
1818

1919
"github.com/mudler/LocalAI/internal"
20+
"github.com/mudler/LocalAI/pkg/httpclient"
2021
)
2122

2223
// Release represents a LocalAI release
@@ -67,9 +68,7 @@ func NewReleaseManager() *ReleaseManager {
6768
CurrentVersion: internal.PrintableVersion(),
6869
ChecksumsPath: checksumsPath,
6970
MetadataPath: metadataPath,
70-
HTTPClient: &http.Client{
71-
Timeout: 30 * time.Second,
72-
},
71+
HTTPClient: httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects()),
7372
}
7473
}
7574

core/cli/workerregistry/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"time"
1616

1717
"github.com/mudler/xlog"
18+
19+
"github.com/mudler/LocalAI/pkg/httpclient"
1820
)
1921

2022
// RegistrationClient talks to the frontend's /api/node/* endpoints.
@@ -37,7 +39,7 @@ func (c *RegistrationClient) httpTimeout() time.Duration {
3739
// httpClient returns the shared HTTP client, initializing it on first use.
3840
func (c *RegistrationClient) httpClient() *http.Client {
3941
c.clientOnce.Do(func() {
40-
c.client = &http.Client{Timeout: c.httpTimeout()}
42+
c.client = httpclient.NewWithTimeout(c.httpTimeout())
4143
})
4244
return c.client
4345
}

core/clients/store.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
10+
"github.com/mudler/LocalAI/pkg/httpclient"
911
)
1012

1113
// Define a struct to hold the store API client
@@ -47,7 +49,7 @@ type FindResponse struct {
4749
func NewStoreClient(baseUrl string) *StoreClient {
4850
return &StoreClient{
4951
BaseURL: baseUrl,
50-
Client: &http.Client{},
52+
Client: httpclient.New(),
5153
}
5254
}
5355

core/config/gen_inference_defaults/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"io"
12-
"net/http"
1312
"os"
1413
"sort"
1514
"strings"
15+
16+
"github.com/mudler/LocalAI/pkg/httpclient"
1617
)
1718

1819
const (
@@ -55,7 +56,7 @@ var allowedFields = map[string]bool{
5556
func main() {
5657
fmt.Fprintf(os.Stderr, "Fetching %s ...\n", unslothURL)
5758

58-
resp, err := http.Get(unslothURL)
59+
resp, err := httpclient.New(httpclient.WithFollowRedirects()).Get(unslothURL)
5960
if err != nil {
6061
fatal("fetch failed: %v", err)
6162
}

core/http/auth/oauth.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"golang.org/x/oauth2"
2020
githubOAuth "golang.org/x/oauth2/github"
2121
"gorm.io/gorm"
22+
23+
"github.com/mudler/LocalAI/pkg/httpclient"
2224
)
2325

2426
// providerEntry holds the OAuth2/OIDC config for a single provider.
@@ -389,7 +391,7 @@ func fetchGitHubUserInfoAsOAuth(ctx context.Context, accessToken string) (*oauth
389391
}
390392

391393
func fetchGitHubUserInfo(ctx context.Context, accessToken string) (*githubUserInfo, error) {
392-
client := &http.Client{Timeout: 10 * time.Second}
394+
client := httpclient.NewWithTimeout(10 * time.Second)
393395

394396
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
395397
req.Header.Set("Authorization", "Bearer "+accessToken)
@@ -420,7 +422,7 @@ func fetchGitHubUserInfo(ctx context.Context, accessToken string) (*githubUserIn
420422
}
421423

422424
func fetchGitHubPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
423-
client := &http.Client{Timeout: 10 * time.Second}
425+
client := httpclient.NewWithTimeout(10 * time.Second)
424426

425427
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
426428
req.Header.Set("Authorization", "Bearer "+accessToken)
@@ -458,7 +460,6 @@ func fetchGitHubPrimaryEmail(ctx context.Context, accessToken string) (string, e
458460
return "", fmt.Errorf("no verified email found")
459461
}
460462

461-
462463
func generateState() (string, error) {
463464
b := make([]byte, 16)
464465
if _, err := rand.Read(b); err != nil {

core/http/endpoints/localai/audio.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"time"
1212

1313
"github.com/labstack/echo/v4"
14+
15+
"github.com/mudler/LocalAI/pkg/httpclient"
1416
"github.com/mudler/LocalAI/pkg/utils"
1517
)
1618

@@ -22,7 +24,9 @@ import (
2224
// decoding on the leading `data:` bytes.
2325
var audioDataURIPattern = regexp.MustCompile(`^data:[^,]+?;base64,`)
2426

25-
var audioDownloadClient = http.Client{Timeout: 30 * time.Second}
27+
// Downloading user-supplied media URLs legitimately follows redirects (CDNs);
28+
// WithFollowRedirects still strips any credential header on a cross-host hop.
29+
var audioDownloadClient = httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects())
2630

2731
// decodeAudioInput materialises a URL / data-URI / raw-base64 audio
2832
// payload to a temporary file and returns its path plus a cleanup

0 commit comments

Comments
 (0)