mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-05 15:26:14 -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>
311 lines
7.6 KiB
Go
311 lines
7.6 KiB
Go
package openai
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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"
|
|
)
|
|
|
|
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 := httpclient.New(httpclient.WithFollowRedirects()).Get(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Create the file
|
|
out, err := os.CreateTemp("", "image")
|
|
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"
|
|
}'
|
|
|
|
*
|
|
*/
|
|
// ImageEndpoint is the OpenAI Image generation API endpoint https://platform.openai.com/docs/api-reference/images/create
|
|
// @Summary Creates an image given a prompt.
|
|
// @Tags images
|
|
// @Param request body schema.OpenAIRequest true "query params"
|
|
// @Success 200 {object} schema.OpenAIResponse "Response"
|
|
// @Router /v1/images/generations [post]
|
|
func ImageEndpoint(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.OpenAIRequest)
|
|
if !ok || input.Model == "" {
|
|
xlog.Error("Image Endpoint - Invalid Input")
|
|
return echo.ErrBadRequest
|
|
}
|
|
|
|
config, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
|
if !ok || config == nil {
|
|
xlog.Error("Image Endpoint - Invalid Config")
|
|
return echo.ErrBadRequest
|
|
}
|
|
|
|
// Process input images (for img2img/inpainting)
|
|
src := ""
|
|
if input.File != "" {
|
|
src = processImageFile(input.File, appConfig.GeneratedContentDir)
|
|
if src != "" {
|
|
defer os.RemoveAll(src)
|
|
}
|
|
}
|
|
|
|
// Process multiple input images
|
|
var inputImages []string
|
|
if len(input.Files) > 0 {
|
|
for _, file := range input.Files {
|
|
processedFile := processImageFile(file, appConfig.GeneratedContentDir)
|
|
if processedFile != "" {
|
|
inputImages = append(inputImages, processedFile)
|
|
defer os.RemoveAll(processedFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process reference images
|
|
var refImages []string
|
|
if len(input.RefImages) > 0 {
|
|
for _, file := range input.RefImages {
|
|
processedFile := processImageFile(file, appConfig.GeneratedContentDir)
|
|
if processedFile != "" {
|
|
refImages = append(refImages, processedFile)
|
|
defer os.RemoveAll(processedFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
xlog.Debug("Parameter Config", "config", config)
|
|
|
|
switch config.Backend {
|
|
case "stablediffusion":
|
|
config.Backend = model.StableDiffusionGGMLBackend
|
|
case "":
|
|
config.Backend = model.StableDiffusionGGMLBackend
|
|
}
|
|
|
|
if !strings.Contains(input.Size, "x") {
|
|
input.Size = "512x512"
|
|
xlog.Warn("Invalid size, using default 512x512")
|
|
}
|
|
|
|
sizeParts := strings.Split(input.Size, "x")
|
|
if len(sizeParts) != 2 {
|
|
return fmt.Errorf("invalid value for 'size'")
|
|
}
|
|
width, err := strconv.Atoi(sizeParts[0])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid value for 'size'")
|
|
}
|
|
height, err := strconv.Atoi(sizeParts[1])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid value for 'size'")
|
|
}
|
|
|
|
b64JSON := config.ResponseFormat == "b64_json"
|
|
|
|
// src and clip_skip
|
|
var result []schema.Item
|
|
for _, i := range config.PromptStrings {
|
|
n := input.N
|
|
if input.N == 0 {
|
|
n = 1
|
|
}
|
|
for range n {
|
|
prompts := strings.Split(i, "|")
|
|
positive_prompt := prompts[0]
|
|
negative_prompt := ""
|
|
if len(prompts) > 1 {
|
|
negative_prompt = prompts[1]
|
|
}
|
|
|
|
step := config.Step
|
|
if step == 0 {
|
|
step = 15
|
|
}
|
|
|
|
if input.Step != 0 {
|
|
step = input.Step
|
|
}
|
|
|
|
tempDir := ""
|
|
if !b64JSON {
|
|
tempDir = filepath.Join(appConfig.GeneratedContentDir, "images")
|
|
}
|
|
// Create a temporary file
|
|
outputFile, err := os.CreateTemp(tempDir, "b64")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outputFile.Close()
|
|
|
|
output := outputFile.Name() + ".png"
|
|
// Rename the temporary file
|
|
err = os.Rename(outputFile.Name(), output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
baseURL := middleware.BaseURL(c)
|
|
|
|
// Use the first input image as src if available, otherwise use the original src
|
|
inputSrc := src
|
|
if len(inputImages) > 0 {
|
|
inputSrc = inputImages[0]
|
|
}
|
|
|
|
fn, err := backend.ImageGeneration(c.Request().Context(), height, width, step, *config.Seed, positive_prompt, negative_prompt, inputSrc, output, ml, *config, appConfig, refImages)
|
|
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-images", base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
result = append(result, *item)
|
|
}
|
|
}
|
|
|
|
id := uuid.New().String()
|
|
created := int(time.Now().Unix())
|
|
resp := &schema.OpenAIResponse{
|
|
ID: id,
|
|
Created: created,
|
|
Data: result,
|
|
Usage: &schema.OpenAIUsage{
|
|
PromptTokens: 0,
|
|
CompletionTokens: 0,
|
|
TotalTokens: 0,
|
|
InputTokens: 0,
|
|
OutputTokens: 0,
|
|
InputTokensDetails: &schema.InputTokensDetails{
|
|
TextTokens: 0,
|
|
ImageTokens: 0,
|
|
},
|
|
},
|
|
}
|
|
|
|
jsonResult, _ := json.Marshal(resp)
|
|
xlog.Debug("Response", "response", string(jsonResult))
|
|
|
|
// Return the prediction in the response body
|
|
return c.JSON(200, resp)
|
|
}
|
|
}
|
|
|
|
// processImageFile handles a single image file (URL or base64) and returns the path to the temporary file
|
|
func processImageFile(file string, generatedContentDir string) string {
|
|
fileData := []byte{}
|
|
var err error
|
|
|
|
// check if file is an URL, if so download it and save it to a temporary file
|
|
if strings.HasPrefix(file, "http://") || strings.HasPrefix(file, "https://") {
|
|
out, err := downloadFile(file)
|
|
if err != nil {
|
|
xlog.Error("Failed downloading file", "error", err, "file", file)
|
|
return ""
|
|
}
|
|
defer os.RemoveAll(out)
|
|
|
|
fileData, err = os.ReadFile(out)
|
|
if err != nil {
|
|
xlog.Error("Failed reading downloaded file", "error", err, "file", out)
|
|
return ""
|
|
}
|
|
} else {
|
|
// base 64 decode the file and write it somewhere that we will cleanup
|
|
fileData, err = base64.StdEncoding.DecodeString(file)
|
|
if err != nil {
|
|
xlog.Error("Failed decoding base64 file", "error", err)
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Create a temporary file
|
|
outputFile, err := os.CreateTemp(generatedContentDir, "b64")
|
|
if err != nil {
|
|
xlog.Error("Failed creating temporary file", "error", err)
|
|
return ""
|
|
}
|
|
|
|
// write the decoded result
|
|
writer := bufio.NewWriter(outputFile)
|
|
_, err = writer.Write(fileData)
|
|
if err != nil {
|
|
outputFile.Close()
|
|
xlog.Error("Failed writing to temporary file", "error", err)
|
|
return ""
|
|
}
|
|
if err := writer.Flush(); err != nil {
|
|
outputFile.Close()
|
|
xlog.Error("Failed flushing to temporary file", "error", err)
|
|
return ""
|
|
}
|
|
outputFile.Close()
|
|
|
|
return outputFile.Name()
|
|
}
|