mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-07 05:43:30 -04:00
fix(nemo-speech-cpp): audit the gosec unsafe and file-inclusion sites
gosec flags 13 alerts on this backend: one G304 and twelve G103. Each was checked individually rather than blanket-suppressed, and each annotation states what makes that particular site safe. The G304 at audio.go is a false positive. The opened path is filepath.Join of a directory the function just created with os.MkdirTemp and a constant basename; the request-controlled path is the input to AudioToWav and never reaches the open. The twelve G103 sites are the package's three established shapes, and every one was verified against them: cstr and pinPtr take the address of something pinned on the line above and return it one-way (nothing in the package converts either result back, which is what keeps checkptr out of it under -race), and each *Create hands C a stack-local POD config whose uintptr members are cstr allocations or pinPtr addresses held by a pinner the loader unpins only after the call. The two slice-building sites are bounded by construction: DiarSegments is handed exactly len(buf) with the buffer sized under maxDiarSegments and a reported count larger than it rejected rather than sliced to, and the TTS callback copies out a slice whose length is the length the runtime declared for that buffer. Separately, sampleRateOf gets a real fix rather than an annotation. go-audio reads the WAV header's sample rate from an unsigned 32-bit field into an int, so a header claiming more than 2^31-1 passed the "> 0" test and then narrowed to a NEGATIVE rate, which the runtime would take as a resampling ratio. AudioToWav cannot produce one today, but that is a property of another package and this function exists precisely because the rate is read back rather than assumed, so the bound is enforced here and pinned by a spec. The four remaining integer narrowings are annotated with the bound that makes each safe: the WAV payload length is already checked against maxWAVDataBytes, the speaker count is bounded by maxDiarSegments, and the two segment ids are the proto's own int32 wire type. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -34,6 +34,10 @@ type asrWord struct {
|
||||
// address C holds stays the address of the object.
|
||||
func pinPtr[T any](p *runtime.Pinner, v *T) uintptr {
|
||||
p.Pin(v)
|
||||
// #nosec G103 -- v is pinned into p on the previous line, so its address is
|
||||
// stable and traced for as long as p lives; every caller defers p.Unpin only
|
||||
// after the create call that reads it. One-way, like cstr: nothing converts
|
||||
// this uintptr back to a pointer.
|
||||
return uintptr(unsafe.Pointer(v))
|
||||
}
|
||||
|
||||
@@ -137,6 +141,10 @@ func (n *NemoSpeech) loadASR(modelFile string) error {
|
||||
"itn", n.opts.itnDir != "",
|
||||
"diarization", n.opts.diarModel != "")
|
||||
|
||||
// #nosec G103 -- cfg is a local POD struct passed as a pointer for the
|
||||
// duration of this call only; every uintptr member it carries is either a
|
||||
// cstr allocation or a pinPtr address, all pinned above and released by the
|
||||
// defers, and nemo_speech_asr_create deep-copies and retains nothing.
|
||||
if st := ASRCreate(unsafe.Pointer(&cfg), &n.recognizer); st != 0 {
|
||||
return statusErrorf(st, "nemo-speech-cpp: asr create: %s", ASRLastError())
|
||||
}
|
||||
@@ -158,6 +166,10 @@ func recognizeF32(recognizer uintptr, opts *cASRRecognitionOptions, pcm []float3
|
||||
}
|
||||
|
||||
var result uintptr
|
||||
// #nosec G103 -- opts is the caller's live struct, borrowed for this call
|
||||
// only; its LanguageCode is a cstr allocation the caller keeps pinned across
|
||||
// it. &pcm[0] is guarded by the empty check above and the length handed over
|
||||
// is exactly len(pcm), so the runtime cannot read past the slice.
|
||||
if st := ASRRecognizeF32(recognizer, unsafe.Pointer(opts),
|
||||
&pcm[0], uint64(len(pcm)), sampleRate, &result); st != 0 {
|
||||
return 0, statusErrorf(st, "nemo-speech-cpp: recognize: %s", ASRLastError())
|
||||
@@ -231,6 +243,9 @@ func wordsToSegments(words []asrWord, withWords bool) []*pb.TranscriptSegment {
|
||||
texts = append(texts, w.Text)
|
||||
}
|
||||
seg := &pb.TranscriptSegment{
|
||||
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and segs
|
||||
// holds one entry per speaker run over the words of a single decode
|
||||
// result, which exhausts memory long before it reaches 2^31.
|
||||
Id: int32(len(segs)),
|
||||
Text: strings.Join(texts, " "),
|
||||
Start: msToNanos(run[0].Start),
|
||||
|
||||
@@ -141,6 +141,10 @@ func (n *NemoSpeech) openSession(language string) (asrSession, error) {
|
||||
// them.
|
||||
|
||||
var handle uintptr
|
||||
// #nosec G103 -- opts is a local POD struct borrowed for this call only, and
|
||||
// its one uintptr member (LanguageCode) is the cstr allocation pinned by the
|
||||
// deferred freeLang above. to_options copies the struct, so nothing here
|
||||
// outlives the call.
|
||||
if st := ASRStreamingRecognize(n.recognizer, unsafe.Pointer(&opts), &handle); st != 0 {
|
||||
return nil, statusErrorf(st, "nemo-speech-cpp: streaming recognize: %s", ASRLastError())
|
||||
}
|
||||
@@ -244,6 +248,9 @@ func streamPCM(ctx context.Context, sess asrSession, pcm []float32, sampleRate i
|
||||
segs = []*pb.TranscriptSegment{{Text: r.Text}}
|
||||
}
|
||||
for _, s := range segs {
|
||||
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and
|
||||
// segments holds one entry per speaker run per finalized utterance of
|
||||
// a single request, which exhausts memory long before it reaches 2^31.
|
||||
s.Id = int32(len(segments))
|
||||
segments = append(segments, s)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -278,6 +279,22 @@ var _ = Describe("sampleRateOf", func() {
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
// The WAV header carries the sample rate as an unsigned 32-bit field, which
|
||||
// go-audio widens to int. Anything above the int32 range therefore passes a
|
||||
// "> 0" test and then narrows to a NEGATIVE rate, which the runtime would take
|
||||
// as a resampling ratio rather than reject. The failure is silent, so the
|
||||
// bound is asserted rather than left to the caller.
|
||||
//
|
||||
// Written as a conversion plus one rather than as the constant MaxInt32+1:
|
||||
// the untyped form does not fit an int on a 32-bit build and would not
|
||||
// compile there, while this wraps to a negative rate the same guard rejects.
|
||||
It("rejects a rate that would not survive the narrowing to int32", func() {
|
||||
_, err := sampleRateOf(&audio.IntBuffer{
|
||||
Format: &audio.Format{SampleRate: int(math.MaxInt32) + 1, NumChannels: 1},
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns the decoded rate", func() {
|
||||
rate, err := sampleRateOf(&audio.IntBuffer{Format: &audio.Format{SampleRate: 22050, NumChannels: 1}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -33,6 +34,9 @@ func decodeAudioMono16k(path string) ([]float32, int32, error) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// #nosec G304 -- converted is filepath.Join of a directory this function just
|
||||
// created with os.MkdirTemp and a constant basename. The request-controlled
|
||||
// path is the INPUT to AudioToWav and never reaches this open.
|
||||
fh, err := os.Open(converted)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -64,8 +68,18 @@ func decodeAudioMono16k(path string) ([]float32, int32, error) {
|
||||
// decoder could not read would not fail, it would silently pitch-shift the
|
||||
// audio and quietly degrade the transcript, which is the same failure the
|
||||
// caller comment warns about for a wrong rate.
|
||||
//
|
||||
// The upper bound is what makes the narrowing to int32 safe rather than merely
|
||||
// unlikely. go-audio reads the WAV header's sample rate as an unsigned 32-bit
|
||||
// field into an int, so on a 64-bit build a header claiming more than 2^31-1
|
||||
// survives the "> 0" test and then narrows to a NEGATIVE rate, which the runtime
|
||||
// would take as a resampling ratio. Nothing this backend decodes can reach that
|
||||
// today (AudioToWav either passes through a WAV it has confirmed is exactly
|
||||
// 16 kHz or runs ffmpeg with -ar 16000), but that is a property of a helper in
|
||||
// another package, and this function exists precisely because the rate is read
|
||||
// back rather than assumed.
|
||||
func sampleRateOf(buf *audio.IntBuffer) (int32, error) {
|
||||
if buf.Format == nil || buf.Format.SampleRate <= 0 {
|
||||
if buf.Format == nil || buf.Format.SampleRate <= 0 || buf.Format.SampleRate > math.MaxInt32 {
|
||||
return 0, errors.New("nemo-speech-cpp: decoded audio has no usable sample rate")
|
||||
}
|
||||
return int32(buf.Format.SampleRate), nil
|
||||
|
||||
@@ -87,6 +87,10 @@ func (s *cDiarStream) cfgPtr() unsafe.Pointer {
|
||||
if s.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
// #nosec G103 -- a plain *T to unsafe.Pointer conversion of a non-nil,
|
||||
// GC-traced field. cDiarSegmentationConfig is pure scalars (no uintptr
|
||||
// members to pin) and the stream owns it for its whole life, so the only
|
||||
// requirement is that it outlive the DiarSegments call, which it does.
|
||||
return unsafe.Pointer(s.cfg)
|
||||
}
|
||||
|
||||
@@ -129,6 +133,10 @@ func (s *cDiarStream) fillSegments(buf []cDiarSegment) (uint64, error) {
|
||||
"nemo-speech-cpp: diarization segment fill needs a buffer")
|
||||
}
|
||||
var count uint64
|
||||
// #nosec G103 -- &buf[0] is guarded by the empty check above, and the
|
||||
// capacity handed over is exactly len(buf), so the runtime cannot write past
|
||||
// the caller's allocation. collectSegments sizes buf under maxDiarSegments
|
||||
// and rejects a reported count larger than it rather than slicing to it.
|
||||
st := DiarSegments(s.handle, s.cfgPtr(), unsafe.Pointer(&buf[0]), uint64(len(buf)), &count)
|
||||
if st != 0 {
|
||||
// count is returned alongside the error on purpose: a too-small buffer
|
||||
@@ -187,6 +195,10 @@ func (n *NemoSpeech) loadDiarizer(modelFile string) error {
|
||||
|
||||
xlog.Info("nemo-speech-cpp: creating diarizer", "gpu", n.opts.gpu)
|
||||
|
||||
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
|
||||
// only uintptr member is ModelPath, the cstr allocation pinned by the
|
||||
// deferred freePath above (Preset is deliberately NULL), and
|
||||
// nemo_speech_diar_create deep-copies the path and retains nothing.
|
||||
if st := DiarCreate(unsafe.Pointer(&cfg), &n.diarizer); st != 0 {
|
||||
return statusErrorf(st, "nemo-speech-cpp: diarizer create: %s", ASRLastError())
|
||||
}
|
||||
@@ -379,6 +391,9 @@ func distinctSpeakers(segs []*pb.DiarizeSegment) int32 {
|
||||
for _, s := range segs {
|
||||
seen[s.GetSpeaker()] = struct{}{}
|
||||
}
|
||||
// #nosec G115 -- seen holds at most one entry per segment, and collectSegments
|
||||
// refuses any count above maxDiarSegments (2^22), so this is orders of
|
||||
// magnitude below the int32 the proto field is.
|
||||
return int32(len(seen))
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ func cstr(s string) (uintptr, func()) {
|
||||
b := append([]byte(s), 0)
|
||||
pin := new(runtime.Pinner)
|
||||
pin.Pin(&b[0])
|
||||
// #nosec G103 -- b is non-empty (s != "" above) and &b[0] is pinned on the
|
||||
// previous line, so the address C receives cannot be collected or moved
|
||||
// until the returned release runs. One-way by construction: the doc comment
|
||||
// above forbids converting this uintptr back, which is what keeps checkptr
|
||||
// (and therefore -race) out of it.
|
||||
return uintptr(unsafe.Pointer(&b[0])), func() {
|
||||
if pin == nil {
|
||||
return
|
||||
|
||||
@@ -178,6 +178,10 @@ func (n *NemoSpeech) loadNMT(modelFile string) error {
|
||||
"source_language", n.opts.sourceLanguage,
|
||||
"target_language", n.opts.targetLanguage)
|
||||
|
||||
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
|
||||
// Backend and Model members are pinPtr addresses held by the pinner unpinned
|
||||
// on return, Model.Path is the cstr allocation freed by the defer above, and
|
||||
// nemo_speech_nmt_create deep-copies everything it reads.
|
||||
if st := NMTCreate(unsafe.Pointer(&cfg), &n.nmt); st != 0 {
|
||||
return statusErrorf(st, "nemo-speech-cpp: nmt create: %s", NMTLastError())
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ func ttsDeliverPCM(pcm unsafe.Pointer, nBytes uint64, userData uintptr) bool {
|
||||
}
|
||||
|
||||
buf := make([]byte, nBytes)
|
||||
// #nosec G103 -- pcm and nBytes are the C-owned buffer and its length from
|
||||
// one callback invocation, both null/zero-checked above. The slice is read
|
||||
// only, its length is the length the runtime declared for that buffer, and it
|
||||
// is copied into Go memory here and never retained past this return.
|
||||
copy(buf, unsafe.Slice((*byte)(pcm), nBytes))
|
||||
return sink(buf)
|
||||
}
|
||||
@@ -200,6 +204,10 @@ func (s *cSynthesizer) synthesize(req *pb.TTSRequest, defaultLanguage string, si
|
||||
// stats_out is NULL: nemo_speech_tts_synthesis_stats is 300-odd bytes of
|
||||
// timing detail with nowhere to go on either RPC, and the C API documents
|
||||
// NULL as the way to decline it.
|
||||
// #nosec G103 -- opts is a local POD struct borrowed for this call only. Its
|
||||
// two uintptr members (LanguageCode, VoiceName) are cstr allocations pinned
|
||||
// by the defers above, and this entry point is synchronous, so it returns
|
||||
// before those pins are released even though the callbacks run off-thread.
|
||||
st := TTSSynthesizeText(s.handle, unsafe.Pointer(&opts), req.GetText(), ttsPCMCallback(), id, nil)
|
||||
if st != 0 {
|
||||
// An unknown voice_name arrives here as INVALID_ARGUMENT
|
||||
@@ -394,6 +402,10 @@ func (n *NemoSpeech) loadTTS(modelFile string) error {
|
||||
// load, where the operator can see it, rather than the first synthesis.
|
||||
ttsPCMCallback()
|
||||
|
||||
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Model
|
||||
// and Runtime are pinPtr addresses held by the pinner unpinned on return, the
|
||||
// paths they carry are cstr allocations freed by the defers above, and
|
||||
// nemo_speech_tts_create deep-copies every string it reads.
|
||||
if st := TTSCreate(unsafe.Pointer(&cfg), &n.synth); st != 0 {
|
||||
return statusErrorf(st, "nemo-speech-cpp: tts create: %s", TTSLastError())
|
||||
}
|
||||
@@ -453,6 +465,8 @@ func wavFile(pcm []byte, sampleRate uint32) ([]byte, error) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
// #nosec G115 -- len(pcm) is checked against maxWAVDataBytes (MaxUint32 minus
|
||||
// the header) immediately above, so the narrowing to uint32 cannot wrap.
|
||||
h := laudio.NewWAVHeaderWithRate(uint32(len(pcm)), sampleRate)
|
||||
if err := h.Write(&buf); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "nemo-speech-cpp: write WAV header: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user