diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 000000000..81c1d873d --- /dev/null +++ b/.jules/sentinel.md @@ -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. diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index b8b9319aa..7f6cf7696 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -60,7 +61,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha 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() @@ -100,7 +101,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } 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)) } if typ != websocket.MessageText { continue diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 590cbc8b4..55d026f48 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -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() @@ -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 @@ -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`. diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 8ece95306..4b65dd5f0 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -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) + } +}