fix(localai-proxy): clear the gosec findings

Code scanning flagged seven issues in the new backend:

- G115 text.go: tool-call indexes and tokenize lengths come from the
  upstream server as int and were cast straight to int32. Add clampInt32 so
  an absurd upstream value saturates instead of wrapping.
- G115 live.go: the int16 -> uint16 cast in PCM16 encoding is a deliberate
  two's-complement reinterpretation of an already clamped sample; mark it
  with #nosec and say so.
- G304 proxy.go, media.go, client.go: api_key_file comes from the model
  config, and the media input and output paths are files core staged or
  chose for the call. None are caller-supplied. Clean the paths and add
  #nosec with that reason, as core/gallery and the sound classification
  endpoint already do.
- G306 media.go: write generated media 0o600. Core runs as the same user
  and serves the file itself.

gosec reports 0 issues for backend/go/localai-proxy and
core/services/failover. The G104 once reported for failover/prober.go is
no longer present.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-27 07:42:21 +00:00
1 parent 14c692098e
commit 53ce024efc
6 files changed
+40 -6

No files matched your search

+2 -1
View File
@@ -355,7 +355,8 @@ func (p *LocalAIProxy) doToFile(req *http.Request, path, dst string) (http.Heade
}
defer func() { _ = resp.Body.Close() }()
f, err := os.Create(dst)
// #nosec G304 -- dst is the output path core chose for this call (generated content dir), never a caller-supplied path
f, err := os.Create(filepath.Clean(dst))
if err != nil {
return nil, status.Errorf(codes.Internal, "localai-proxy: create %s: %v", dst, err)
}
+1
View File
@@ -399,6 +399,7 @@ func pcm16LE(pcm []float32) []byte {
v = 0
}
v = math.Max(-1, math.Min(1, v))
// #nosec G115 -- two's-complement reinterpretation for little-endian PCM16 encoding, value range already clamped
binary.LittleEndian.PutUint16(buf[i*2:], uint16(int16(v*math.MaxInt16)))
}
return buf
+6 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
@@ -26,7 +27,8 @@ func fileToBase64(path string) (string, error) {
if path == "" {
return "", nil
}
data, err := os.ReadFile(path)
// #nosec G304 -- path is a staging file core wrote for this call, never a caller-supplied path
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return "", status.Errorf(codes.InvalidArgument, "localai-proxy: read %s: %v", path, err)
}
@@ -82,7 +84,9 @@ func (p *LocalAIProxy) writeGenItem(ctx context.Context, path string, items []ge
if err != nil {
return status.Errorf(codes.Internal, "localai-proxy: decode %s b64_json: %v", path, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
// 0o600: core runs as the same user and serves the file itself, so no
// one else needs to read generated media.
if err := os.WriteFile(dst, data, 0o600); err != nil {
_ = os.Remove(dst)
return status.Errorf(codes.Internal, "localai-proxy: write %s: %v", dst, err)
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
@@ -159,7 +160,8 @@ func resolveAPIKey(envName, filePath string) (string, error) {
return v, nil
}
if filePath != "" {
b, err := os.ReadFile(filePath)
// #nosec G304 -- api_key_file comes from the operator's model config (passed by core as a backend option), not from a request
b, err := os.ReadFile(filepath.Clean(filePath))
if err != nil {
return "", fmt.Errorf("localai-proxy: read api_key_file %q: %w", filePath, err)
}
+17 -2
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"math"
"strings"
"github.com/mudler/xlog"
@@ -181,7 +182,7 @@ func replyFromChoice(c textChoice, streaming bool) *pb.Reply {
delta := &pb.ChatDelta{Content: content, ReasoningContent: reasoning}
for _, tc := range d.ToolCalls {
delta.ToolCalls = append(delta.ToolCalls, &pb.ToolCallDelta{
Index: int32(tc.Index),
Index: clampInt32(tc.Index),
Id: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
@@ -350,7 +351,7 @@ func (p *LocalAIProxy) TokenizeString(opts *pb.PredictOptions) (pb.TokenizationR
if err := p.postJSON(context.Background(), "/v1/tokenize", body, &resp); err != nil {
return pb.TokenizationResponse{}, err
}
return pb.TokenizationResponse{Length: int32(len(resp.Tokens)), Tokens: resp.Tokens}, nil
return pb.TokenizationResponse{Length: clampInt32(len(resp.Tokens)), Tokens: resp.Tokens}, nil
}
func (p *LocalAIProxy) Detokenize(in *pb.DetokenizeRequest) (pb.DetokenizeResponse, error) {
@@ -402,3 +403,17 @@ func (p *LocalAIProxy) Score(ctx context.Context, in *pb.ScoreRequest) (*pb.Scor
}
return out, nil
}
// clampInt32 narrows an upstream-supplied int (token counts, tool-call
// indexes) to the int32 the gRPC protocol carries. The upstream is another
// server, so an absurd value must saturate rather than wrap to a negative or
// small number that core would take at face value.
func clampInt32(n int) int32 {
switch {
case n > math.MaxInt32:
return math.MaxInt32
case n < math.MinInt32:
return math.MinInt32
}
return int32(n)
}
+11
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"math"
"net/http"
"os"
"path/filepath"
@@ -548,3 +549,13 @@ var _ = Describe("localai-proxy", func() {
})
})
})
var _ = Describe("clampInt32", func() {
It("passes in-range values through and saturates the rest", func() {
Expect(clampInt32(0)).To(Equal(int32(0)))
Expect(clampInt32(42)).To(Equal(int32(42)))
Expect(clampInt32(-7)).To(Equal(int32(-7)))
Expect(clampInt32(math.MaxInt32 + 1)).To(Equal(int32(math.MaxInt32)))
Expect(clampInt32(math.MinInt32 - 1)).To(Equal(int32(math.MinInt32)))
})
})