mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-07 00:06:51 -04:00
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>
154 lines
3.1 KiB
Go
154 lines
3.1 KiB
Go
package clients
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
)
|
|
|
|
// Define a struct to hold the store API client
|
|
type StoreClient struct {
|
|
BaseURL string
|
|
Client *http.Client
|
|
}
|
|
|
|
type SetRequest struct {
|
|
Keys [][]float32 `json:"keys"`
|
|
Values []string `json:"values"`
|
|
}
|
|
|
|
type GetRequest struct {
|
|
Keys [][]float32 `json:"keys"`
|
|
}
|
|
|
|
type GetResponse struct {
|
|
Keys [][]float32 `json:"keys"`
|
|
Values []string `json:"values"`
|
|
}
|
|
|
|
type DeleteRequest struct {
|
|
Keys [][]float32 `json:"keys"`
|
|
}
|
|
|
|
type FindRequest struct {
|
|
TopK int `json:"topk"`
|
|
Key []float32 `json:"key"`
|
|
}
|
|
|
|
type FindResponse struct {
|
|
Keys [][]float32 `json:"keys"`
|
|
Values []string `json:"values"`
|
|
Similarities []float32 `json:"similarities"`
|
|
}
|
|
|
|
// Constructor for StoreClient
|
|
func NewStoreClient(baseUrl string) *StoreClient {
|
|
return &StoreClient{
|
|
BaseURL: baseUrl,
|
|
Client: httpclient.New(),
|
|
}
|
|
}
|
|
|
|
// Implement Set method
|
|
func (c *StoreClient) Set(req SetRequest) error {
|
|
return c.doRequest("stores/set", req)
|
|
}
|
|
|
|
// Implement Get method
|
|
func (c *StoreClient) Get(req GetRequest) (*GetResponse, error) {
|
|
body, err := c.doRequestWithResponse("stores/get", req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp GetResponse
|
|
err = json.Unmarshal(body, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// Implement Delete method
|
|
func (c *StoreClient) Delete(req DeleteRequest) error {
|
|
return c.doRequest("stores/delete", req)
|
|
}
|
|
|
|
// Implement Find method
|
|
func (c *StoreClient) Find(req FindRequest) (*FindResponse, error) {
|
|
body, err := c.doRequestWithResponse("stores/find", req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp FindResponse
|
|
err = json.Unmarshal(body, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// Helper function to perform a request without expecting a response body
|
|
func (c *StoreClient) doRequest(path string, data any) error {
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.BaseURL+"/"+path, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.Client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("API request to %s failed with status code %d", path, resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper function to perform a request and parse the response body
|
|
func (c *StoreClient) doRequestWithResponse(path string, data any) ([]byte, error) {
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.BaseURL+"/"+path, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.Client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("API request to %s failed with status code %d", path, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return body, nil
|
|
}
|