Files
LocalAI/core/http/endpoints/localai/audio.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

93 lines
3.1 KiB
Go

package localai
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/pkg/httpclient"
"github.com/mudler/LocalAI/pkg/utils"
)
// Match `data:<mime>[;param=value...];base64,` — MediaRecorder in the browser
// produces data URIs like `data:audio/webm;codecs=opus;base64,...`, so the
// pre-`;base64,` section can contain zero or more parameter segments. The
// old `([^;]+)` form only matched exactly one segment and left recordings
// from the React UI's live-capture tab unparsed, which then failed base64
// decoding on the leading `data:` bytes.
var audioDataURIPattern = regexp.MustCompile(`^data:[^,]+?;base64,`)
// Downloading user-supplied media URLs legitimately follows redirects (CDNs);
// WithFollowRedirects still strips any credential header on a cross-host hop.
var audioDownloadClient = httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects())
// decodeAudioInput materialises a URL / data-URI / raw-base64 audio
// payload to a temporary file and returns its path plus a cleanup
// function. Voice backends expect a filesystem path (same convention
// as TranscriptRequest.dst) — callers must defer the returned cleanup
// so the temp file does not leak.
//
// Bad inputs (invalid URL, undecodable base64, non-audio payload) are
// surfaced as 400 Bad Request rather than 500 so API consumers can
// distinguish a client mistake from a server failure.
func decodeAudioInput(s string) (string, func(), error) {
if s == "" {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, "audio input is empty")
}
var raw []byte
switch {
case strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://"):
if err := utils.ValidateExternalURL(s); err != nil {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid audio URL: %v", err))
}
resp, err := audioDownloadClient.Get(s)
if err != nil {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("audio download failed: %v", err))
}
defer resp.Body.Close()
raw, err = io.ReadAll(resp.Body)
if err != nil {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("audio download read failed: %v", err))
}
default:
payload := s
if m := audioDataURIPattern.FindString(s); m != "" {
payload = strings.Replace(s, m, "", 1)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid audio base64: %v", err))
}
raw = decoded
}
if len(raw) == 0 {
return "", func() {}, echo.NewHTTPError(http.StatusBadRequest, "audio input decoded to zero bytes")
}
f, err := os.CreateTemp("", "localai-voice-*.wav")
if err != nil {
return "", func() {}, err
}
path := f.Name()
cleanup := func() { _ = os.Remove(path) }
if _, err := f.Write(raw); err != nil {
f.Close()
cleanup()
return "", func() {}, err
}
if err := f.Close(); err != nil {
cleanup()
return "", func() {}, err
}
return path, cleanup, nil
}