mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-15 04:08:55 -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>
262 lines
5.9 KiB
Go
262 lines
5.9 KiB
Go
package localai
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/middleware"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
|
|
"github.com/mudler/LocalAI/core/backend"
|
|
|
|
"github.com/mudler/xlog"
|
|
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
model "github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
)
|
|
|
|
// Downloading user-supplied media URLs legitimately follows redirects (CDNs);
|
|
// WithFollowRedirects still strips any credential header on a cross-host hop.
|
|
var videoDownloadClient = httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects())
|
|
|
|
func downloadFile(url string) (string, error) {
|
|
if err := utils.ValidateExternalURL(url); err != nil {
|
|
return "", fmt.Errorf("URL validation failed: %w", err)
|
|
}
|
|
|
|
// Get the data
|
|
resp, err := videoDownloadClient.Get(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Create the file
|
|
out, err := os.CreateTemp("", "video")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
// Write the body to file
|
|
_, err = io.Copy(out, resp.Body)
|
|
return out.Name(), err
|
|
}
|
|
|
|
//
|
|
|
|
/*
|
|
*
|
|
|
|
curl http://localhost:8080/v1/images/generations \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"prompt": "A cute baby sea otter",
|
|
"n": 1,
|
|
"size": "512x512"
|
|
}'
|
|
|
|
*
|
|
*/
|
|
// VideoEndpoint
|
|
// @Summary Creates a video given a prompt.
|
|
// @Tags video
|
|
// @Param request body schema.VideoRequest true "query params"
|
|
// @Success 200 {object} schema.OpenAIResponse "Response"
|
|
// @Router /video [post]
|
|
func VideoEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.VideoRequest)
|
|
if !ok || input.Model == "" {
|
|
xlog.Error("Video Endpoint - Invalid Input")
|
|
return echo.ErrBadRequest
|
|
}
|
|
|
|
config, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
|
if !ok || config == nil {
|
|
xlog.Error("Video Endpoint - Invalid Config")
|
|
return echo.ErrBadRequest
|
|
}
|
|
|
|
// Stage a base64- or URL-provided image into a temp file so the
|
|
// backend can read it as a path. Used for both start_image and
|
|
// (optional) end_image. Returns the temp file path, or "" if the
|
|
// input is empty. Caller is responsible for the defer-cleanup.
|
|
stageImage := func(ref string) (string, error) {
|
|
if ref == "" {
|
|
return "", nil
|
|
}
|
|
var fileData []byte
|
|
var err error
|
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
|
out, derr := downloadFile(ref)
|
|
if derr != nil {
|
|
return "", fmt.Errorf("failed downloading file: %w", derr)
|
|
}
|
|
defer os.RemoveAll(out)
|
|
fileData, err = os.ReadFile(out)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed reading file: %w", err)
|
|
}
|
|
} else {
|
|
fileData, err = base64.StdEncoding.DecodeString(ref)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
outputFile, err := os.CreateTemp(appConfig.GeneratedContentDir, "b64")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
writer := bufio.NewWriter(outputFile)
|
|
if _, err := writer.Write(fileData); err != nil {
|
|
outputFile.Close()
|
|
return "", err
|
|
}
|
|
if err := writer.Flush(); err != nil {
|
|
outputFile.Close()
|
|
return "", err
|
|
}
|
|
outputFile.Close()
|
|
return outputFile.Name(), nil
|
|
}
|
|
|
|
src, err := stageImage(input.StartImage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if src != "" {
|
|
defer os.RemoveAll(src)
|
|
}
|
|
|
|
endSrc, err := stageImage(input.EndImage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if endSrc != "" {
|
|
defer os.RemoveAll(endSrc)
|
|
}
|
|
|
|
xlog.Debug("Parameter Config", "config", config)
|
|
|
|
switch config.Backend {
|
|
case "stablediffusion":
|
|
config.Backend = model.StableDiffusionGGMLBackend
|
|
case "":
|
|
config.Backend = model.StableDiffusionGGMLBackend
|
|
}
|
|
|
|
width := input.Width
|
|
height := input.Height
|
|
|
|
if width == 0 {
|
|
width = 512
|
|
}
|
|
if height == 0 {
|
|
height = 512
|
|
}
|
|
|
|
b64JSON := input.ResponseFormat == "b64_json"
|
|
|
|
tempDir := ""
|
|
if !b64JSON {
|
|
tempDir = filepath.Join(appConfig.GeneratedContentDir, "videos")
|
|
}
|
|
// Create a temporary file
|
|
outputFile, err := os.CreateTemp(tempDir, "b64")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outputFile.Close()
|
|
|
|
// TODO: use mime type to determine the extension
|
|
output := outputFile.Name() + ".mp4"
|
|
|
|
// Rename the temporary file
|
|
err = os.Rename(outputFile.Name(), output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
baseURL := middleware.BaseURL(c)
|
|
|
|
xlog.Debug("VideoEndpoint: Calling VideoGeneration",
|
|
"num_frames", input.NumFrames,
|
|
"fps", input.FPS,
|
|
"cfg_scale", input.CFGScale,
|
|
"step", input.Step,
|
|
"seed", input.Seed,
|
|
"width", width,
|
|
"height", height,
|
|
"negative_prompt", input.NegativePrompt)
|
|
|
|
fn, err := backend.VideoGeneration(
|
|
height,
|
|
width,
|
|
input.Prompt,
|
|
input.NegativePrompt,
|
|
src,
|
|
endSrc,
|
|
output,
|
|
input.NumFrames,
|
|
input.FPS,
|
|
input.Seed,
|
|
input.CFGScale,
|
|
input.Step,
|
|
ml,
|
|
*config,
|
|
appConfig,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(); err != nil {
|
|
return err
|
|
}
|
|
|
|
item := &schema.Item{}
|
|
|
|
if b64JSON {
|
|
defer os.RemoveAll(output)
|
|
data, err := os.ReadFile(output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item.B64JSON = base64.StdEncoding.EncodeToString(data)
|
|
} else {
|
|
base := filepath.Base(output)
|
|
item.URL, err = url.JoinPath(baseURL, "generated-videos", base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
id := uuid.New().String()
|
|
created := int(time.Now().Unix())
|
|
resp := &schema.OpenAIResponse{
|
|
ID: id,
|
|
Created: created,
|
|
Data: []schema.Item{*item},
|
|
}
|
|
|
|
jsonResult, _ := json.Marshal(resp)
|
|
xlog.Debug("Response", "response", string(jsonResult))
|
|
|
|
// Return the prediction in the response body
|
|
return c.JSON(200, resp)
|
|
}
|
|
}
|