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 <viettranx@gmail.com>
This commit is contained in:
Junghwanandviettranx authored and GitHub committed 2026-04-17 20:15:44 +07:00
1 parent 93ce4fa9d9
commit f729b2d618
2 files changed
+44

No files matched your search

+6
View File
@@ -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 {
+38
View File
@@ -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)
}
}