From f729b2d6187a24b432359ea8efbe2aadb8da2fd2 Mon Sep 17 00:00:00 2001 From: Junghwan <70629228+shaun0927@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:15:44 +0900 Subject: [PATCH] Prevent TTS test-connection from bypassing provider URL guards (#937) The repo already validates provider base URLs during provider CRUD, but TTS test-connection built ephemeral providers from user-supplied api_base without reusing that protection. This change applies the same validation at test time and adds regression coverage for blocked loopback targets. Constraint: Must preserve current local-provider SSRF policy semantics from provider validation Rejected: Restrict test-connection to admins only | larger behavior change than needed for the bug Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep TTS test-connection URL validation aligned with validateProviderURL behavior for provider CRUD Tested: go test ./internal/http -run 'TestTestConnection_(MissingProvider|UnsupportedProvider|MissingAPIKey|BelowOperator|BlockedAPIBase)$' -count=1 -v Tested: go build ./... Tested: go build -tags sqliteonly ./... Not-tested: Full repo race test suite Co-authored-by: viettranx --- internal/http/tts_test_connection.go | 6 ++++ internal/http/tts_test_connection_test.go | 38 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/internal/http/tts_test_connection.go b/internal/http/tts_test_connection.go index fec16af2..a17270fb 100644 --- a/internal/http/tts_test_connection.go +++ b/internal/http/tts_test_connection.go @@ -104,6 +104,12 @@ func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request if req.APIBase == "" { req.APIBase = req.BaseURL } + + if err := validateProviderURL(req.APIBase, req.Provider); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + return + } + // Create ephemeral provider. provider, err := createEphemeralTTSProvider(req) if err != nil { diff --git a/internal/http/tts_test_connection_test.go b/internal/http/tts_test_connection_test.go index 4e6ae8c5..e8338bf6 100644 --- a/internal/http/tts_test_connection_test.go +++ b/internal/http/tts_test_connection_test.go @@ -132,3 +132,41 @@ func TestTestConnection_BelowOperator(t *testing.T) { t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String()) } } + +// TestTestConnection_BlockedAPIBase verifies test-connection reuses provider URL SSRF validation. +func TestTestConnection_BlockedAPIBase(t *testing.T) { + setupTestToken(t, ttsTestToken) + + operatorRaw := "test-conn-operator-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(operatorRaw): { + ID: uuid.New(), + Scopes: []string{"operator.write"}, + }, + }) + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{ + "provider": "openai", + "api_key": "sk-test", + "api_base": "http://127.0.0.1:8080/v1", + })) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+operatorRaw) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if errStr, _ := resp["error"].(string); errStr != "provider URL cannot point to 127.0.0.1" { + t.Fatalf("unexpected error: %q", errStr) + } +}