Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

## 2025-02-14 - Prevent API key leak in streaming dictation websocket errors
**Vulnerability:** Streaming dictation (Deepgram, OpenAI Realtime) did not redact API keys from connection errors or websocket payload error responses.
**Learning:** API keys are injected into streaming websocket URLs or headers which can leak when transport-level errors occur or when the server responds with a typed error payload echoing the key.
**Prevention:** Apply `providerio.Redact` around all error endpoints and websocket error returns.
5 changes: 3 additions & 2 deletions internal/dictation/transcriber_deepgram.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"strconv"
"strings"

"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)

Expand Down Expand Up @@ -60,7 +61,7 @@
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
})
if err != nil {
return "", fmt.Errorf("connecting to Deepgram: %w", err)
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
}
defer conn.CloseNow()

Expand Down Expand Up @@ -100,7 +101,7 @@
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))

Check failure on line 104 in internal/dictation/transcriber_deepgram.go

View workflow job for this annotation

GitHub Actions / Security & code health

ST1005: error strings should not be capitalized (staticcheck)
}
if typ != websocket.MessageText {
continue
Expand Down
7 changes: 4 additions & 3 deletions internal/dictation/transcriber_openai_realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"strings"

"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)

Expand Down Expand Up @@ -59,7 +60,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
})
if err != nil {
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
defer conn.CloseNow()

Expand Down Expand Up @@ -113,7 +114,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
}
default:
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
if typ != websocket.MessageText {
continue
Expand Down Expand Up @@ -147,7 +148,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case realtimeCommitted:
committed = true
case realtimeError:
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
}
// The writer signals commit completion out of band; observe it so a
// stop with no further audio still flips `committed`.
Expand Down
55 changes: 55 additions & 0 deletions internal/dictation/transcriber_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,58 @@ func TestParseRealtimeEvent(t *testing.T) {
t.Errorf("error parse: %+v", e)
}
}

func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
for {
typ, _, err := c.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
}
})

tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}

chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}

func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// close immediately with an error that simulates connection issue with API key
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
})

tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}

chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
Loading