diff --git a/internal/agent/component/model_driver_test.go b/internal/agent/component/model_driver_test.go index 84fc1b288b..69503da5e8 100644 --- a/internal/agent/component/model_driver_test.go +++ b/internal/agent/component/model_driver_test.go @@ -12,6 +12,7 @@ import ( ) func TestNewChatModelDriverPreservesProviderChatSuffix(t *testing.T) { + setupAllowAnyHost(t, true) if err := models.InitProviderManager("../../../conf/models"); err != nil { t.Fatalf("InitProviderManager: %v", err) } diff --git a/internal/entity/models/302ai.go b/internal/entity/models/302ai.go index fa2dfaa5e6..2f3b4e1769 100644 --- a/internal/entity/models/302ai.go +++ b/internal/entity/models/302ai.go @@ -42,7 +42,7 @@ func NewAI302Model(baseURL map[string]string, urlSuffix URLSuffix) *AI302Model { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/302ai_test.go b/internal/entity/models/302ai_test.go index 1c7eace058..fbb8f52807 100644 --- a/internal/entity/models/302ai_test.go +++ b/internal/entity/models/302ai_test.go @@ -69,6 +69,7 @@ func newAI302ForTest(baseURL string) *AI302Model { } func TestAI302ChatForcesNonStreaming(t *testing.T) { + withSSRFBypass(t) srv := newAI302Server(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { t.Errorf("method=%s, want POST", r.Method) @@ -114,6 +115,7 @@ func TestAI302ChatForcesNonStreaming(t *testing.T) { } func TestAI302StreamForcesStreaming(t *testing.T) { + withSSRFBypass(t) srv := newAI302Server(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/chat/completions" { t.Errorf("path=%s, want /v1/chat/completions", r.URL.Path) @@ -167,6 +169,7 @@ func TestAI302StreamForcesStreaming(t *testing.T) { } func TestAI302ListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newAI302Server(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { t.Errorf("method=%s, want GET", r.Method) @@ -195,6 +198,7 @@ func TestAI302ListModelsHappyPath(t *testing.T) { } func TestAI302ListModelsRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" for name, response := range map[string]interface{}{ "missing data": map[string]interface{}{"object": "list"}, @@ -215,6 +219,7 @@ func TestAI302ListModelsRejectsMalformedResponse(t *testing.T) { } func TestAI302ShowTaskEscapesTaskID(t *testing.T) { + withSSRFBypass(t) srv := newAI302Server(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { t.Errorf("method=%s, want GET", r.Method) @@ -245,6 +250,7 @@ func TestAI302ShowTaskEscapesTaskID(t *testing.T) { } func TestAI302ValidatesInputs(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" emptyKey := " " model := "gpt-5" diff --git a/internal/entity/models/aliyun.go b/internal/entity/models/aliyun.go index fc2cdc208e..6dcad3dff5 100644 --- a/internal/entity/models/aliyun.go +++ b/internal/entity/models/aliyun.go @@ -39,7 +39,7 @@ func NewAliyunModel(baseURL map[string]string, urlSuffix URLSuffix) *AliyunModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/aliyun_test.go b/internal/entity/models/aliyun_test.go index 4d5d86c569..e9ae59263e 100644 --- a/internal/entity/models/aliyun_test.go +++ b/internal/entity/models/aliyun_test.go @@ -25,6 +25,7 @@ import ( ) func TestAliyunChatWithMessagesSupportsToolCalls(t *testing.T) { + withSSRFBypass(t) requestBody := make(chan map[string]interface{}, 1) requestPath := make(chan string, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -87,6 +88,7 @@ func TestAliyunChatWithMessagesSupportsToolCalls(t *testing.T) { } func TestAliyunChatWithMessagesStopsQwenFlashAfterToolResult(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() requestBody := make(chan map[string]interface{}, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -188,6 +190,7 @@ func TestAliyunToolChoiceStopsOnlyQwenFlashAfterToolResult(t *testing.T) { } func TestAliyunChatStreamlyWithSenderSupportsToolCalls(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() requestBody := make(chan map[string]interface{}, 1) requestPath := make(chan string, 1) @@ -282,6 +285,7 @@ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ra } func TestAliyunChatStreamlyWithSenderRejectsStreamFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := NewAliyunModel( map[string]string{"default": "https://dashscope.example"}, diff --git a/internal/entity/models/anthropic.go b/internal/entity/models/anthropic.go index cb3155a3ae..8513d319ae 100644 --- a/internal/entity/models/anthropic.go +++ b/internal/entity/models/anthropic.go @@ -41,7 +41,7 @@ func NewAnthropicModel(baseURL map[string]string, urlSuffix URLSuffix) *Anthropi baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/anthropic_test.go b/internal/entity/models/anthropic_test.go index 2c8db61e64..8540f456af 100644 --- a/internal/entity/models/anthropic_test.go +++ b/internal/entity/models/anthropic_test.go @@ -61,6 +61,7 @@ func TestAnthropicName(t *testing.T) { } func TestAnthropicChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "claude-sonnet-4-5-20250929" { t.Errorf("model=%v", body["model"]) @@ -109,6 +110,7 @@ func TestAnthropicChatHappyPath(t *testing.T) { } func TestAnthropicChatMapsSystemConfigAndImages(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["system"] != "be concise" { t.Errorf("system=%v, want be concise", body["system"]) @@ -186,6 +188,7 @@ func TestAnthropicChatMapsSystemConfigAndImages(t *testing.T) { } func TestAnthropicChatMapsDataImageURL(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { msgs, ok := body["messages"].([]interface{}) if !ok || len(msgs) == 0 { @@ -239,6 +242,7 @@ func TestAnthropicChatMapsDataImageURL(t *testing.T) { } func TestAnthropicChatValidationErrors(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newAnthropicForTest("http://unused") apiKey := "test-key" @@ -263,6 +267,7 @@ func TestAnthropicChatValidationErrors(t *testing.T) { } func TestAnthropicChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -278,6 +283,7 @@ func TestAnthropicChatRejectsHTTPError(t *testing.T) { } func TestAnthropicChatRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"content": []map[string]interface{}{{"type": "tool_use"}}}) @@ -292,6 +298,7 @@ func TestAnthropicChatRejectsMalformedResponse(t *testing.T) { } func TestAnthropicChatStreamlyWithSenderHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != true { t.Errorf("stream=%v, want true", body["stream"]) @@ -364,6 +371,7 @@ func TestAnthropicChatStreamlyWithSenderHappyPath(t *testing.T) { } func TestAnthropicChatStreamlyWithSenderRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":{"message":"bad key"}}`)) @@ -387,6 +395,7 @@ func TestAnthropicChatStreamlyWithSenderRejectsHTTPError(t *testing.T) { } func TestAnthropicChatStreamlyWithSenderRejectsStreamError(t *testing.T) { + withSSRFBypass(t) srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte(`data: {"type":"error","error":{"message":"overloaded"}}` + "\n\n")) @@ -410,6 +419,7 @@ func TestAnthropicChatStreamlyWithSenderRejectsStreamError(t *testing.T) { } func TestAnthropicListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var calls int srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { @@ -441,6 +451,7 @@ func TestAnthropicListModelsAndCheckConnection(t *testing.T) { } func TestAnthropicListModelsRejectsProviderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusForbidden) @@ -466,6 +477,7 @@ func TestAnthropicFactoryRegistration(t *testing.T) { } func TestAnthropicUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newAnthropicForTest("http://unused") apiKey := "test-key" diff --git a/internal/entity/models/astraflow.go b/internal/entity/models/astraflow.go index 7cceac6c10..c144ecfbb2 100644 --- a/internal/entity/models/astraflow.go +++ b/internal/entity/models/astraflow.go @@ -54,7 +54,7 @@ func NewAstraflowModel(baseURL map[string]string, urlSuffix URLSuffix) *Astraflo baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/astraflow_test.go b/internal/entity/models/astraflow_test.go index 3a41ac73f4..e7b275d9c9 100644 --- a/internal/entity/models/astraflow_test.go +++ b/internal/entity/models/astraflow_test.go @@ -92,6 +92,7 @@ func TestAstraflowFactory(t *testing.T) { } func TestAstraflowChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "claude-opus-4-7" { @@ -140,6 +141,7 @@ func TestAstraflowChatHappyPath(t *testing.T) { } func TestAstraflowChatNoReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -168,6 +170,7 @@ func TestAstraflowChatNoReasoning(t *testing.T) { } func TestAstraflowChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newAstraflowForTest("http://unused").ChatWithMessages( ctx, @@ -180,6 +183,7 @@ func TestAstraflowChatRequiresAPIKey(t *testing.T) { } func TestAstraflowChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newAstraflowForTest("http://unused").ChatWithMessages( @@ -190,6 +194,7 @@ func TestAstraflowChatRequiresMessages(t *testing.T) { } func TestAstraflowChatPropagatesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -209,6 +214,7 @@ func TestAstraflowChatPropagatesHTTPError(t *testing.T) { } func TestAstraflowStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowSSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -249,6 +255,7 @@ func TestAstraflowStreamHappyPath(t *testing.T) { } func TestAstraflowStreamSplitsReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowSSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -290,6 +297,7 @@ func TestAstraflowStreamSplitsReasoning(t *testing.T) { } func TestAstraflowStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -307,6 +315,7 @@ func TestAstraflowStreamRejectsExplicitFalse(t *testing.T) { } func TestAstraflowStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newAstraflowForTest("http://unused").ChatStreamlyWithSender( @@ -320,6 +329,7 @@ func TestAstraflowStreamRequiresSender(t *testing.T) { } func TestAstraflowStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", @@ -339,6 +349,7 @@ func TestAstraflowStreamFailsWithoutTerminal(t *testing.T) { } func TestAstraflowStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ @@ -359,6 +370,7 @@ func TestAstraflowStreamRejectsMalformedFrame(t *testing.T) { } func TestAstraflowStreamSurfacesUpstreamError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ @@ -382,6 +394,7 @@ func TestAstraflowStreamSurfacesUpstreamError(t *testing.T) { } func TestAstraflowListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -406,6 +419,7 @@ func TestAstraflowListModelsHappyPath(t *testing.T) { } func TestAstraflowListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newAstraflowForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -414,6 +428,7 @@ func TestAstraflowListModelsRequiresAPIKey(t *testing.T) { } func TestAstraflowCheckConnectionDelegatesToListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -429,6 +444,7 @@ func TestAstraflowCheckConnectionDelegatesToListModels(t *testing.T) { } func TestAstraflowCheckConnectionPropagatesError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -444,6 +460,7 @@ func TestAstraflowCheckConnectionPropagatesError(t *testing.T) { } func TestAstraflowBaseURLForRegionUnknown(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newAstraflowForTest("http://unused") apiKey := "test-key" @@ -455,6 +472,7 @@ func TestAstraflowBaseURLForRegionUnknown(t *testing.T) { } func TestAstraflowEmbedReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Embed IS implemented (not a stub). It should NOT be blocked by APIConfigCheck. // With empty input texts it short-circuits to empty result (no error). @@ -466,6 +484,7 @@ func TestAstraflowEmbedReturnsNoSuchMethod(t *testing.T) { } func TestAstraflowAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newAstraflowForTest("http://unused") model := "x" diff --git a/internal/entity/models/avian.go b/internal/entity/models/avian.go index edc1f9ff1e..fb67eec171 100644 --- a/internal/entity/models/avian.go +++ b/internal/entity/models/avian.go @@ -40,7 +40,7 @@ func NewAvianModel(baseURL map[string]string, urlSuffix URLSuffix) *AvianModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/avian_test.go b/internal/entity/models/avian_test.go index cc1302a17a..607bf45c9f 100644 --- a/internal/entity/models/avian_test.go +++ b/internal/entity/models/avian_test.go @@ -79,6 +79,7 @@ func TestAvianFactory(t *testing.T) { } func TestAvianChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/chat/completions" { @@ -138,6 +139,7 @@ func TestAvianChatHappyPath(t *testing.T) { } func TestAvianChatFallsBackToReasoningField(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -169,6 +171,7 @@ func TestAvianChatFallsBackToReasoningField(t *testing.T) { } func TestAvianChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newAvianForTest("http://unused").ChatWithMessages( ctx, @@ -184,6 +187,7 @@ func TestAvianChatRequiresAPIKey(t *testing.T) { } func TestAvianChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newAvianForTest("http://unused").ChatWithMessages( @@ -200,6 +204,7 @@ func TestAvianChatRequiresMessages(t *testing.T) { } func TestAvianChatPropagatesUpstreamErrorStatus(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -222,6 +227,7 @@ func TestAvianChatPropagatesUpstreamErrorStatus(t *testing.T) { } func TestAvianStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/chat/completions" { @@ -277,6 +283,7 @@ func TestAvianStreamHappyPath(t *testing.T) { } func TestAvianStreamRejectsFalseStreamConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -295,6 +302,7 @@ func TestAvianStreamRejectsFalseStreamConfig(t *testing.T) { } func TestAvianStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newAvianForTest("http://unused").ChatStreamlyWithSender( @@ -312,6 +320,7 @@ func TestAvianStreamRequiresSender(t *testing.T) { } func TestAvianListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newAvianServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/models" { @@ -336,6 +345,7 @@ func TestAvianListModelsAndCheckConnection(t *testing.T) { } func TestAvianMissingBaseURLFailsClearly(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() a := NewAvianModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"}) apiKey := "test-key" @@ -353,6 +363,7 @@ func TestAvianMissingBaseURLFailsClearly(t *testing.T) { } func TestAvianUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() a := newAvianForTest("http://unused") model := "deepseek/deepseek-v3.2" diff --git a/internal/entity/models/azure_openai.go b/internal/entity/models/azure_openai.go index 55c5febcb5..44b355e39d 100644 --- a/internal/entity/models/azure_openai.go +++ b/internal/entity/models/azure_openai.go @@ -41,7 +41,7 @@ func NewAzureOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *AzureO baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/baichuan.go b/internal/entity/models/baichuan.go index 8f1e368d99..217752d572 100644 --- a/internal/entity/models/baichuan.go +++ b/internal/entity/models/baichuan.go @@ -36,7 +36,7 @@ func NewBaichuanModel(baseURL map[string]string, urlSuffix URLSuffix) *BaichuanM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/baidu.go b/internal/entity/models/baidu.go index 8a31d77bf6..a6dcb178df 100644 --- a/internal/entity/models/baidu.go +++ b/internal/entity/models/baidu.go @@ -41,7 +41,7 @@ func NewBaiduModel(baseURL map[string]string, urlSuffix URLSuffix) *BaiduModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/base_model.go b/internal/entity/models/base_model.go index ea154153f7..0cd4b888a4 100644 --- a/internal/entity/models/base_model.go +++ b/internal/entity/models/base_model.go @@ -26,6 +26,7 @@ import ( "net/http" "ragflow/internal/common" "ragflow/internal/engine/clickhouse" + "ragflow/internal/utility" "sort" "strings" "time" @@ -362,7 +363,17 @@ func ParseListModel(modelList ModelList) []ListModelResponse { } // NewDriverHTTPClient returns an *http.Client with the standard connection-pool -func NewDriverHTTPClient() *http.Client { +// settings and an SSRF guard wired into its Transport. +// +// allowPrivate selects the guard strictness: +// - false (cloud-hosted drivers): every request is validated with +// utility.AssertURLSafe — scheme + host must be present and every resolved +// IP must be globally routable (private/loopback/link-local/metadata are +// rejected). This is the default and closes the go/request-forgery sink. +// - true (local-inference drivers): requests are validated with +// utility.AssertURLSchemeSafe — only the scheme and a non-empty host are +// enforced, so self-hosted backends on private networks or loopback work. +func NewDriverHTTPClient(allowPrivate bool) *http.Client { var t *http.Transport if dt, ok := http.DefaultTransport.(*http.Transport); ok { t = dt.Clone() @@ -375,7 +386,41 @@ func NewDriverHTTPClient() *http.Client { t.DisableCompression = false t.ResponseHeaderTimeout = 2 * 60 * time.Second t.TLSHandshakeTimeout = 30 * time.Second - return &http.Client{Transport: t} + + var rt http.RoundTripper = t + if allowPrivate { + rt = &schemeSafeTransport{base: rt} + } else { + rt = &strictSSRFTransport{base: rt} + } + return &http.Client{Transport: rt} +} + +// schemeSafeTransport wraps an http.RoundTripper so every outgoing request is +// validated by the lenient SSRF guard (http/https scheme + non-empty host). +// Private and loopback hosts are permitted. Used only by local-inference +// drivers that may target a user's own network. +type schemeSafeTransport struct{ base http.RoundTripper } + +func (t *schemeSafeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := utility.AssertURLSchemeSafe(req.URL.String()); err != nil { + return nil, err + } + return t.base.RoundTrip(req) +} + +// strictSSRFTransport wraps an http.RoundTripper so every outgoing request is +// validated by the strict SSRF guard (scheme + host + globally routable IP). +// This is the default for cloud-hosted model drivers and closes the +// go/request-forgery data flow: the user-controllable BaseURL cannot be made to +// point at private hosts, loopback, link-local, or cloud metadata endpoints. +type strictSSRFTransport struct{ base http.RoundTripper } + +func (t *strictSSRFTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if _, _, err := utility.AssertURLSafe(req.URL.String()); err != nil { + return nil, err + } + return t.base.RoundTrip(req) } // PostJSONRequest marshals body to JSON, creates a POST request to url diff --git a/internal/entity/models/bedrock.go b/internal/entity/models/bedrock.go index 6b42401368..d46ac59723 100644 --- a/internal/entity/models/bedrock.go +++ b/internal/entity/models/bedrock.go @@ -105,7 +105,7 @@ func NewBedrockModel(baseURL map[string]string, urlSuffix URLSuffix) *BedrockMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/bedrock_test.go b/internal/entity/models/bedrock_test.go index 9687e1dec0..a1e333cc47 100644 --- a/internal/entity/models/bedrock_test.go +++ b/internal/entity/models/bedrock_test.go @@ -327,6 +327,7 @@ func newBedrockServer(t *testing.T, wantMethod, wantPath string, handler http.Ha } func TestBedrockChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/anthropic.claude-3-haiku-20240307-v1:0/converse", @@ -369,6 +370,7 @@ func TestBedrockChatHappyPath(t *testing.T) { } func TestBedrockChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") _, err := m.ChatWithMessages(ctx, "m", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil) @@ -378,6 +380,7 @@ func TestBedrockChatRequiresAPIKey(t *testing.T) { } func TestBedrockChatRequiresModelID(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") key := validBedrockKey() @@ -389,6 +392,7 @@ func TestBedrockChatRequiresModelID(t *testing.T) { } func TestBedrockChatPropagatesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/m/converse", @@ -412,6 +416,7 @@ func TestBedrockChatPropagatesHTTPError(t *testing.T) { } func TestBedrockListModelsParsesCatalog(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodGet, "/foundation-models", @@ -450,6 +455,7 @@ func TestBedrockListModelsParsesCatalog(t *testing.T) { } func TestBedrockCheckConnectionDelegates(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodGet, "/foundation-models", @@ -503,6 +509,7 @@ func encodeBedrockEventFrames(t *testing.T, events []struct { } func TestBedrockStreamDecodesContentDeltas(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() frames := encodeBedrockEventFrames(t, []struct { eventType string @@ -553,6 +560,7 @@ func TestBedrockStreamDecodesContentDeltas(t *testing.T) { } func TestBedrockStreamSurfacesException(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() frames := encodeBedrockEventFrames(t, []struct { eventType string @@ -586,6 +594,7 @@ func TestBedrockStreamSurfacesException(t *testing.T) { } func TestBedrockStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Connection closed cleanly after a delta but before messageStop. // This used to be silently treated as success, masking truncated @@ -618,6 +627,7 @@ func TestBedrockStreamFailsWithoutTerminal(t *testing.T) { } func TestBedrockStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") key := validBedrockKey() @@ -634,6 +644,7 @@ func TestBedrockStreamRejectsExplicitFalse(t *testing.T) { } func TestBedrockStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") key := validBedrockKey() @@ -659,6 +670,7 @@ func TestLookupBedrockEventHeader(t *testing.T) { } func TestBedrockTitanEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seenInputs []string srv := newBedrockServer(t, http.MethodPost, @@ -702,6 +714,7 @@ func TestBedrockTitanEmbedHappyPath(t *testing.T) { } func TestBedrockTitanV1OmitsDimension(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/amazon.titan-embed-text-v1/invoke", @@ -723,6 +736,7 @@ func TestBedrockTitanV1OmitsDimension(t *testing.T) { } func TestBedrockCohereEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/cohere.embed-english-v3/invoke", @@ -760,6 +774,7 @@ func TestBedrockCohereEmbedHappyPath(t *testing.T) { } func TestBedrockCohereV4ForwardsDimensionAndParsesTypedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/cohere.embed-v4:0/invoke", @@ -790,6 +805,7 @@ func TestBedrockCohereV4ForwardsDimensionAndParsesTypedResponse(t *testing.T) { } func TestBedrockEmbedShortCircuitsEmptyInput(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") got, err := m.Embed(ctx, nil, nil, nil, nil, nil) @@ -802,6 +818,7 @@ func TestBedrockEmbedShortCircuitsEmptyInput(t *testing.T) { } func TestBedrockEmbedRequiresAPIKeyAndModel(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") model := "x" @@ -816,6 +833,7 @@ func TestBedrockEmbedRequiresAPIKeyAndModel(t *testing.T) { } func TestBedrockEmbedRejectsUnsupportedModel(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") key := validBedrockKey() @@ -826,6 +844,7 @@ func TestBedrockEmbedRejectsUnsupportedModel(t *testing.T) { } func TestBedrockEmbedPropagatesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newBedrockServer(t, http.MethodPost, "/model/amazon.titan-embed-text-v2:0/invoke", @@ -844,6 +863,7 @@ func TestBedrockEmbedPropagatesHTTPError(t *testing.T) { } func TestBedrockRerankReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") model := "x" @@ -853,6 +873,7 @@ func TestBedrockRerankReturnsNoSuchMethod(t *testing.T) { } func TestBedrockBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") if _, err := m.Balance(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "no such method") { @@ -861,6 +882,7 @@ func TestBedrockBalanceReturnsNoSuchMethod(t *testing.T) { } func TestBedrockAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newBedrockForTest("http://unused") model := "x" diff --git a/internal/entity/models/chat_usage_test.go b/internal/entity/models/chat_usage_test.go index b07a4094b5..26e51fdf58 100644 --- a/internal/entity/models/chat_usage_test.go +++ b/internal/entity/models/chat_usage_test.go @@ -9,6 +9,7 @@ import ( ) func TestProviderChatUsage(t *testing.T) { + withSSRFBypass(t) type providerCase struct { name string new func(string) ModelDriver diff --git a/internal/entity/models/cohere.go b/internal/entity/models/cohere.go index 9cc5e47706..5e872bbede 100644 --- a/internal/entity/models/cohere.go +++ b/internal/entity/models/cohere.go @@ -44,7 +44,7 @@ func NewCoHereModel(baseURL map[string]string, urlSuffix URLSuffix) *CoHereModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/cohere_test.go b/internal/entity/models/cohere_test.go index 81ea4dfd26..97facea580 100644 --- a/internal/entity/models/cohere_test.go +++ b/internal/entity/models/cohere_test.go @@ -18,6 +18,7 @@ func newCohereForTest(baseURL string) *CoHereModel { } func TestCohereChatAllowsToolCallOnlyResponse(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/chat" { t.Errorf("path=%s", r.URL.Path) @@ -70,6 +71,7 @@ func TestCohereChatAllowsToolCallOnlyResponse(t *testing.T) { } func TestCohereStreamRecordsDeltaUsage(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/chat" { t.Errorf("path=%s", r.URL.Path) diff --git a/internal/entity/models/cometapi.go b/internal/entity/models/cometapi.go index dbef044951..2c2b9a5a83 100644 --- a/internal/entity/models/cometapi.go +++ b/internal/entity/models/cometapi.go @@ -43,7 +43,7 @@ func NewCometAPIModel(baseURL map[string]string, urlSuffix URLSuffix) *CometAPIM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/cometapi_test.go b/internal/entity/models/cometapi_test.go index 1a93719b17..7403a28dfd 100644 --- a/internal/entity/models/cometapi_test.go +++ b/internal/entity/models/cometapi_test.go @@ -77,6 +77,7 @@ func TestCometAPIFactoryRoute(t *testing.T) { } func TestCometAPIChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "gpt-5" { @@ -115,6 +116,7 @@ func TestCometAPIChatHappyPath(t *testing.T) { } func TestCometAPIChatPropagatesConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["max_tokens"] != float64(64) { @@ -152,6 +154,7 @@ func TestCometAPIChatPropagatesConfig(t *testing.T) { } func TestCometAPIChatReturnsReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -174,6 +177,7 @@ func TestCometAPIChatReturnsReasoningContent(t *testing.T) { } func TestCometAPIChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") _, err := m.ChatWithMessages(ctx, "gpt-5", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil) @@ -188,6 +192,7 @@ func TestCometAPIChatRequiresAPIKey(t *testing.T) { } func TestCometAPIChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -202,6 +207,7 @@ func TestCometAPIChatRequiresModelName(t *testing.T) { } func TestCometAPIChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -212,6 +218,7 @@ func TestCometAPIChatRequiresMessages(t *testing.T) { } func TestCometAPIChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -228,6 +235,7 @@ func TestCometAPIChatRejectsHTTPError(t *testing.T) { } func TestCometAPIChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Empty *Region pointer must fall back to the "default" entry, not // be treated as an explicit "" region (which would miss the lookup). @@ -250,6 +258,7 @@ func TestCometAPIChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { } func TestCometAPIListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}}) @@ -265,6 +274,7 @@ func TestCometAPIListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) { } func TestCometAPIStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -277,6 +287,7 @@ func TestCometAPIStreamRequiresSender(t *testing.T) { } func TestCometAPIChatRejectsUnknownRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -289,6 +300,7 @@ func TestCometAPIChatRejectsUnknownRegion(t *testing.T) { } func TestCometAPIBaseURLNormalizesSlashes(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() tests := []struct { name string @@ -359,6 +371,7 @@ func TestCometAPIBaseURLNormalizesSlashes(t *testing.T) { } func TestCometAPIStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != true { @@ -407,6 +420,7 @@ func TestCometAPIStreamHappyPath(t *testing.T) { } func TestCometAPIStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -424,6 +438,7 @@ func TestCometAPIStreamRejectsExplicitFalse(t *testing.T) { } func TestCometAPIStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Body closes before [DONE] or a finish_reason -> driver must complain // instead of pretending the stream finished cleanly. @@ -446,6 +461,7 @@ func TestCometAPIStreamFailsWithoutTerminal(t *testing.T) { } func TestCometAPIListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -470,6 +486,7 @@ func TestCometAPIListModelsHappyPath(t *testing.T) { } func TestCometAPIListModelsAllowsNilAPIConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "gpt-5"}}}) @@ -487,6 +504,7 @@ func TestCometAPIListModelsAllowsNilAPIConfig(t *testing.T) { } func TestCometAPICheckConnectionDelegatesToBalance(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // 200 -> CheckConnection succeeds; 401 -> CheckConnection propagates. okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -518,6 +536,7 @@ func TestCometAPICheckConnectionDelegatesToBalance(t *testing.T) { } func TestCometAPIBalanceHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/user/quota" { @@ -551,6 +570,7 @@ func TestCometAPIBalanceHappyPath(t *testing.T) { } func TestCometAPIBalanceRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") _, err := m.Balance(ctx, &APIConfig{}) @@ -560,6 +580,7 @@ func TestCometAPIBalanceRequiresAPIKey(t *testing.T) { } func TestCometAPIBalanceRequiresConfiguredURL(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") m.baseModel.URLSuffix.Balance = "" @@ -571,6 +592,7 @@ func TestCometAPIBalanceRequiresConfiguredURL(t *testing.T) { } func TestCometAPIRerankReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") q := "gpt-5" @@ -581,6 +603,7 @@ func TestCometAPIRerankReturnsNoSuchMethod(t *testing.T) { } func TestCometAPIEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "text-embedding-3-small" { @@ -619,6 +642,7 @@ func TestCometAPIEmbedHappyPath(t *testing.T) { } func TestCometAPIEmbedReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Upstream returns the three vectors in shuffled order. The driver // must reorder them so the slot at position i corresponds to input i. @@ -648,6 +672,7 @@ func TestCometAPIEmbedReordersByIndex(t *testing.T) { } func TestCometAPIEmbedEmptyInputShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Empty input must NOT make an HTTP call; the test fails the request // rather than the assertion if it does. @@ -670,6 +695,7 @@ func TestCometAPIEmbedEmptyInputShortCircuits(t *testing.T) { } func TestCometAPIEmbedRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") model := "text-embedding-3-small" @@ -680,6 +706,7 @@ func TestCometAPIEmbedRequiresAPIKey(t *testing.T) { } func TestCometAPIEmbedRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newCometAPIForTest("http://unused") apiKey := "test-key" @@ -695,6 +722,7 @@ func TestCometAPIEmbedRequiresModelName(t *testing.T) { } func TestCometAPIEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // A malformed upstream that repeats data[*].index would silently // overwrite the earlier vector; the driver must fail loudly instead. @@ -718,6 +746,7 @@ func TestCometAPIEmbedRejectsDuplicateIndex(t *testing.T) { } func TestCometAPIEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -738,6 +767,7 @@ func TestCometAPIEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestCometAPIEmbedRejectsMissingSlot(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Upstream returns only one of the two requested embeddings. srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { @@ -759,6 +789,7 @@ func TestCometAPIEmbedRejectsMissingSlot(t *testing.T) { } func TestCometAPIEmbedRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) diff --git a/internal/entity/models/deepinfra.go b/internal/entity/models/deepinfra.go index ea1f3f6315..4f3889cd2d 100644 --- a/internal/entity/models/deepinfra.go +++ b/internal/entity/models/deepinfra.go @@ -42,7 +42,7 @@ func NewDeepInfraModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepInfr baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/deepinfra_test.go b/internal/entity/models/deepinfra_test.go index 354d53443c..177a798a1f 100644 --- a/internal/entity/models/deepinfra_test.go +++ b/internal/entity/models/deepinfra_test.go @@ -23,6 +23,7 @@ func newDeepInfraForTest(baseURL string) *DeepInfraModel { // TestDeepInfraRerankHappyPath verifies request shape and score mapping. func TestDeepInfraRerankHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() const modelPath = "/v1/inference/Qwen/Qwen3-Reranker-4B" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -79,6 +80,7 @@ func TestDeepInfraRerankHappyPath(t *testing.T) { // TestDeepInfraRerankNoTopNLimit returns every scored document when TopN is unset. func TestDeepInfraRerankNoTopNLimit(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -110,6 +112,7 @@ func TestDeepInfraRerankNoTopNLimit(t *testing.T) { // TestDeepInfraRerankEmptyDocuments returns an empty result without calling the API. func TestDeepInfraRerankEmptyDocuments(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := "Qwen/Qwen3-Reranker-4B" @@ -124,6 +127,7 @@ func TestDeepInfraRerankEmptyDocuments(t *testing.T) { // TestDeepInfraRerankRequiresAPIKey rejects requests without an API key. func TestDeepInfraRerankRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := "Qwen/Qwen3-Reranker-4B" _, err := newDeepInfraForTest("http://unused").Rerank(ctx, &model, "q", []string{"a"}, &APIConfig{}, nil, nil) @@ -134,6 +138,7 @@ func TestDeepInfraRerankRequiresAPIKey(t *testing.T) { // TestDeepInfraRerankRejectsScoreCountMismatch errors when scores length mismatches documents. func TestDeepInfraRerankRejectsScoreCountMismatch(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"scores": []float64{0.5}}) diff --git a/internal/entity/models/deepseek.go b/internal/entity/models/deepseek.go index bb8b0dde14..0b332713b1 100644 --- a/internal/entity/models/deepseek.go +++ b/internal/entity/models/deepseek.go @@ -39,7 +39,7 @@ func NewDeepSeekModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepSeekM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/deepseek_test.go b/internal/entity/models/deepseek_test.go index bab79a2ea7..34617cfbcf 100644 --- a/internal/entity/models/deepseek_test.go +++ b/internal/entity/models/deepseek_test.go @@ -18,6 +18,7 @@ func newDeepSeekForTest(baseURL string) *DeepSeekModel { } func TestDeepSeekChatWithMessagesSupportsToolCalls(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var requestBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -108,6 +109,7 @@ func TestDeepSeekChatWithMessagesSupportsToolCalls(t *testing.T) { } func TestDeepSeekChatWithMessagesForwardsToolHistory(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var requestBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/entity/models/fishaudio.go b/internal/entity/models/fishaudio.go index 3710da496d..a80d08e635 100644 --- a/internal/entity/models/fishaudio.go +++ b/internal/entity/models/fishaudio.go @@ -41,7 +41,7 @@ func NewFishAudioModel(baseURL map[string]string, urlSuffix URLSuffix) *FishAudi baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/fishaudio_test.go b/internal/entity/models/fishaudio_test.go index 18763327b7..a5da1e15a5 100644 --- a/internal/entity/models/fishaudio_test.go +++ b/internal/entity/models/fishaudio_test.go @@ -28,6 +28,7 @@ func newFishAudioForListModelsTest(baseURL string) *FishAudioModel { } func TestFishAudioListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -62,6 +63,7 @@ func TestFishAudioListModels(t *testing.T) { } func TestFishAudioListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() if _, err := newFishAudioForListModelsTest("http://unused").ListModels(ctx, &APIConfig{}); err == nil { t.Fatal("ListModels: expected error for missing api key, got nil") @@ -69,6 +71,7 @@ func TestFishAudioListModelsRequiresAPIKey(t *testing.T) { } func TestFishAudioListModelsRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) diff --git a/internal/entity/models/funasr.go b/internal/entity/models/funasr.go index e8929061ca..c4b7cbfc86 100644 --- a/internal/entity/models/funasr.go +++ b/internal/entity/models/funasr.go @@ -41,7 +41,7 @@ func NewFunASRModel(baseURL map[string]string, urlSuffix URLSuffix) *FunASR { BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/funasr_test.go b/internal/entity/models/funasr_test.go index e37975b688..9853242f6f 100644 --- a/internal/entity/models/funasr_test.go +++ b/internal/entity/models/funasr_test.go @@ -32,6 +32,7 @@ func writeFunASRTestAudio(t *testing.T) string { } func TestFunASRTranscribeAudioWithoutAPIKey(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method=%s, want POST", r.Method) @@ -67,6 +68,7 @@ func TestFunASRTranscribeAudioWithoutAPIKey(t *testing.T) { } func TestFunASRTranscribeAudioRequiresModelName(t *testing.T) { + withSSRFBypass(t) file := writeFunASRTestAudio(t) apiKey := "test-key" blankModelName := " " @@ -89,6 +91,7 @@ func TestFunASRTranscribeAudioRequiresModelName(t *testing.T) { } func TestFunASRListModelsWithoutAPIKey(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("method=%s, want GET", r.Method) @@ -113,6 +116,7 @@ func TestFunASRListModelsWithoutAPIKey(t *testing.T) { } func TestFunASRListModelsSendsAuthWhenProvided(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer secret" { t.Errorf("Authorization=%q, want Bearer secret", got) diff --git a/internal/entity/models/futurmix.go b/internal/entity/models/futurmix.go index 721f0468a6..cb81d8c4dd 100644 --- a/internal/entity/models/futurmix.go +++ b/internal/entity/models/futurmix.go @@ -38,7 +38,7 @@ func NewFuturMixModel(baseURL map[string]string, urlSuffix URLSuffix) *FuturMixM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/gitee.go b/internal/entity/models/gitee.go index c5eb2883ca..8ba04c792c 100644 --- a/internal/entity/models/gitee.go +++ b/internal/entity/models/gitee.go @@ -40,7 +40,7 @@ func NewGiteeModel(baseURL map[string]string, urlSuffix URLSuffix) *GiteeModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/gitee_test.go b/internal/entity/models/gitee_test.go index 2764a29666..280fefcae4 100644 --- a/internal/entity/models/gitee_test.go +++ b/internal/entity/models/gitee_test.go @@ -51,6 +51,7 @@ func newGiteeForChatTest(baseURL string) *GiteeModel { } func TestGiteeStreamAcceptsTerminalWithoutDelta(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method=%s, want POST", r.Method) @@ -110,6 +111,7 @@ func deepSeekAliasModelsForTest(t *testing.T) map[string]Model { } func TestGiteeListModelsMapsAllDeepSeekAliasesToModelMetadata(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() initProviderManagerWithGiteeForTest(t) aliasModels := deepSeekAliasModelsForTest(t) @@ -187,6 +189,7 @@ func TestGiteeListModelsMapsAllDeepSeekAliasesToModelMetadata(t *testing.T) { } func TestGiteeListModelsKeepsOwnedBySuffixAfterAliasMetadataLookup(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() initProviderManagerWithGiteeForTest(t) @@ -218,6 +221,7 @@ func TestGiteeListModelsKeepsOwnedBySuffixAfterAliasMetadataLookup(t *testing.T) } func TestGiteeListModelsIntegration(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() if common.GetEnv(common.EnvGiteeListModelsIntegration) != "1" { t.Skip("set GITEE_LIST_MODELS_INTEGRATION=1 to call the real Gitee models endpoint") diff --git a/internal/entity/models/google_test.go b/internal/entity/models/google_test.go index 2a4af41e37..1018aaa6c1 100644 --- a/internal/entity/models/google_test.go +++ b/internal/entity/models/google_test.go @@ -28,6 +28,7 @@ func withGoogleListModelsStub(t *testing.T, fn func(context.Context, *genai.Clie } func TestGoogleModelListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} cases := []struct { @@ -83,6 +84,7 @@ func TestGoogleModelListModelsRequiresAPIKey(t *testing.T) { } func TestGoogleModelListModelsReturnsModelNames(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} apiKey := "test-api-key" @@ -106,6 +108,7 @@ func TestGoogleModelListModelsReturnsModelNames(t *testing.T) { } func TestGoogleModelCheckConnectionUsesListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() customBaseURL := "https://check-connection.example.test/google" model := NewGoogleModel(map[string]string{"default": customBaseURL}, URLSuffix{}) @@ -132,6 +135,7 @@ func TestGoogleModelCheckConnectionUsesListModels(t *testing.T) { } func TestGoogleModelCheckConnectionRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} calls := 0 @@ -184,6 +188,7 @@ func TestGoogleModelCheckConnectionRequiresAPIKey(t *testing.T) { } func TestGoogleModelCheckConnectionReturnsListModelsError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} apiKey := "test-api-key" @@ -200,6 +205,7 @@ func TestGoogleModelCheckConnectionReturnsListModelsError(t *testing.T) { } func TestGoogleModelChatStreamlyRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} messages := []Message{{Role: "user", Content: "hello"}} @@ -230,6 +236,7 @@ func TestGoogleModelChatStreamlyRequiresAPIKey(t *testing.T) { } func TestGoogleModelChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := &GoogleModel{} apiKey := "test-api-key" @@ -313,6 +320,7 @@ func TestGoogleModelNewInstancePreservesCustomBaseURL(t *testing.T) { } func TestGoogleModelListModelsPassesBaseURL(t *testing.T) { + withSSRFBypass(t) apiKey := "test-api-key" cases := []struct { name string diff --git a/internal/entity/models/gpustack.go b/internal/entity/models/gpustack.go index 95460b0e2f..fb87f4afde 100644 --- a/internal/entity/models/gpustack.go +++ b/internal/entity/models/gpustack.go @@ -38,7 +38,7 @@ func NewGPUStackModel(baseURL map[string]string, urlSuffix URLSuffix) *GPUStackM BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/gpustack_test.go b/internal/entity/models/gpustack_test.go index 4c3675a5f6..a8b32d21a4 100644 --- a/internal/entity/models/gpustack_test.go +++ b/internal/entity/models/gpustack_test.go @@ -96,6 +96,7 @@ func TestGPUStackFactory(t *testing.T) { } func TestGPUStackChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "qwen3-8b" { t.Errorf("model=%v", body["model"]) @@ -131,6 +132,7 @@ func TestGPUStackChatHappyPath(t *testing.T) { } func TestGPUStackChatExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "choices": []map[string]interface{}{{ @@ -164,6 +166,7 @@ func TestGPUStackChatExtractsReasoningContent(t *testing.T) { } func TestGPUStackChatForwardsDocumentedFields(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { for _, k := range []string{"model", "messages", "stream", "max_tokens", "temperature", "top_p", "stop"} { if _, present := body[k]; !present { @@ -198,6 +201,7 @@ func TestGPUStackChatForwardsDocumentedFields(t *testing.T) { } func TestGPUStackChatAllowsEmptyAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newGPUStackForTest("http://unused").ChatWithMessages( ctx, @@ -211,6 +215,7 @@ func TestGPUStackChatAllowsEmptyAPIKey(t *testing.T) { } func TestGPUStackChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" ctx := t.Context() _, err := newGPUStackForTest("http://unused").ChatWithMessages( @@ -225,6 +230,7 @@ func TestGPUStackChatRequiresModelName(t *testing.T) { } func TestGPUStackChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" ctx := t.Context() _, err := newGPUStackForTest("http://unused").ChatWithMessages( @@ -237,6 +243,7 @@ func TestGPUStackChatRequiresMessages(t *testing.T) { } func TestGPUStackChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) @@ -257,6 +264,7 @@ func TestGPUStackChatRejectsHTTPError(t *testing.T) { } func TestGPUStackChatRequiresBaseURL(t *testing.T) { + withSSRFBypass(t) model := NewGPUStackModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"}) apiKey := "test-key" ctx := t.Context() @@ -272,6 +280,7 @@ func TestGPUStackChatRequiresBaseURL(t *testing.T) { } func TestGPUStackStreamHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ `data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+ @@ -313,6 +322,7 @@ func TestGPUStackStreamHappyPath(t *testing.T) { } func TestGPUStackStreamExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ `data: {"choices":[{"index":0,"delta":{"reasoning_content":"think "}}]}`+"\n"+ @@ -351,6 +361,7 @@ func TestGPUStackStreamExtractsReasoningContent(t *testing.T) { } func TestGPUStackStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" stream := false ctx := t.Context() @@ -369,6 +380,7 @@ func TestGPUStackStreamRejectsExplicitFalse(t *testing.T) { } func TestGPUStackStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" ctx := t.Context() err := newGPUStackForTest("http://unused").ChatStreamlyWithSender( @@ -383,6 +395,7 @@ func TestGPUStackStreamRequiresSender(t *testing.T) { } func TestGPUStackStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", ) @@ -403,6 +416,7 @@ func TestGPUStackStreamFailsWithoutTerminal(t *testing.T) { } func TestGPUStackStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ `data: {this is not valid json}`+"\n", @@ -424,6 +438,7 @@ func TestGPUStackStreamRejectsMalformedFrame(t *testing.T) { } func TestGPUStackStreamSurfacesUpstreamError(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ `data: {"error":{"message":"oom","type":"runtime_error"}}`+"\n", @@ -445,6 +460,7 @@ func TestGPUStackStreamSurfacesUpstreamError(t *testing.T) { } func TestGPUStackListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -480,6 +496,7 @@ func TestGPUStackListModelsHappyPath(t *testing.T) { } func TestGPUStackListModelsAllowsEmptyAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newGPUStackForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || strings.Contains(err.Error(), "api key is required") { @@ -489,6 +506,7 @@ func TestGPUStackListModelsAllowsEmptyAPIKey(t *testing.T) { // TestGPUStackEmbedHappyPath verifies request shape and dimensions on v1-openai/embeddings. func TestGPUStackEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "bge-m3" { t.Errorf("model=%v", body["model"]) @@ -527,6 +545,7 @@ func TestGPUStackEmbedHappyPath(t *testing.T) { // TestGPUStackEmbedReordersByIndex verifies out-of-order response indices are mapped correctly. func TestGPUStackEmbedReordersByIndex(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -555,6 +574,7 @@ func TestGPUStackEmbedReordersByIndex(t *testing.T) { // TestGPUStackEmbedEmptyInputShortCircuits avoids HTTP when texts is empty. func TestGPUStackEmbedEmptyInputShortCircuits(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("Embed([]) made an unexpected HTTP call") w.WriteHeader(http.StatusInternalServerError) @@ -576,6 +596,7 @@ func TestGPUStackEmbedEmptyInputShortCircuits(t *testing.T) { // TestGPUStackEmbedRequiresAPIKey rejects requests without an API key. func TestGPUStackEmbedAllowsEmptyAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := "bge-m3" _, err := newGPUStackForTest("http://unused").Embed(ctx, &model, []string{"a"}, &APIConfig{}, nil, nil) @@ -586,6 +607,7 @@ func TestGPUStackEmbedAllowsEmptyAPIKey(t *testing.T) { // TestGPUStackEmbedRejectsDuplicateIndex errors on duplicate response indices. func TestGPUStackEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -607,6 +629,7 @@ func TestGPUStackEmbedRejectsDuplicateIndex(t *testing.T) { // TestGPUStackEmbedRejectsOutOfRangeIndex errors when index exceeds input length. func TestGPUStackEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -627,6 +650,7 @@ func TestGPUStackEmbedRejectsOutOfRangeIndex(t *testing.T) { // TestGPUStackEmbedRejectsMissingIndex errors when index is omitted from response. func TestGPUStackEmbedRejectsMissingIndex(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -647,6 +671,7 @@ func TestGPUStackEmbedRejectsMissingIndex(t *testing.T) { // TestGPUStackEmbedRejectsEmptyVector errors when the API returns a zero-length vector. func TestGPUStackEmbedRejectsEmptyVector(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -667,6 +692,7 @@ func TestGPUStackEmbedRejectsEmptyVector(t *testing.T) { // TestGPUStackEmbedRejectsMissingSlot errors when a response index is never returned. func TestGPUStackEmbedRejectsMissingSlot(t *testing.T) { + withSSRFBypass(t) srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{ @@ -686,6 +712,7 @@ func TestGPUStackEmbedRejectsMissingSlot(t *testing.T) { } func TestGPUStackUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newGPUStackForTest("http://unused") model := "x" diff --git a/internal/entity/models/greenpt_test.go b/internal/entity/models/greenpt_test.go index 63015bec84..c571ab4307 100644 --- a/internal/entity/models/greenpt_test.go +++ b/internal/entity/models/greenpt_test.go @@ -26,6 +26,7 @@ import ( ) func TestGreenPTListModelsClassifiesSpeech(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { t.Errorf("unexpected path: %s", r.URL.Path) @@ -69,6 +70,7 @@ func TestGreenPTListModelsClassifiesSpeech(t *testing.T) { } func TestGreenPTTranscribeAudio(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/listen" || r.URL.Query().Get("model") != "green-s" { t.Errorf("unexpected request URL: %s", r.URL.String()) diff --git a/internal/entity/models/groq.go b/internal/entity/models/groq.go index 83b8174127..25081df728 100644 --- a/internal/entity/models/groq.go +++ b/internal/entity/models/groq.go @@ -41,7 +41,7 @@ func NewGroqModel(baseURL map[string]string, urlSuffix URLSuffix) *GroqModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/groq_test.go b/internal/entity/models/groq_test.go index 064186416c..a9f931036b 100644 --- a/internal/entity/models/groq_test.go +++ b/internal/entity/models/groq_test.go @@ -113,6 +113,7 @@ func TestNewGroqModelHandlesCustomDefaultTransport(t *testing.T) { } func TestGroqChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { t.Errorf("method=%s", r.Method) @@ -181,6 +182,7 @@ func TestGroqChatHappyPath(t *testing.T) { } func TestGroqChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newGroqForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -190,6 +192,7 @@ func TestGroqChatRequiresModelName(t *testing.T) { } func TestGroqChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newGroqForTest("http://unused").ChatWithMessages(ctx, "llama-3.3-70b-versatile", nil, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -199,6 +202,7 @@ func TestGroqChatRequiresMessages(t *testing.T) { } func TestGroqChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":{"message":"unauthorized"}}`)) @@ -221,6 +225,7 @@ func TestGroqChatRejectsHTTPError(t *testing.T) { } func TestGroqStreamHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { t.Errorf("path=%s", r.URL.Path) @@ -280,6 +285,7 @@ func TestGroqStreamHappyPath(t *testing.T) { } func TestGroqStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" stream := false ctx := t.Context() @@ -298,6 +304,7 @@ func TestGroqStreamRejectsExplicitFalse(t *testing.T) { } func TestGroqStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" ctx := t.Context() err := newGroqForTest("http://unused").ChatStreamlyWithSender( @@ -315,6 +322,7 @@ func TestGroqStreamRequiresSender(t *testing.T) { } func TestGroqStreamRejectsUnterminatedStream(t *testing.T) { + withSSRFBypass(t) srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"partial"}}]}`+"\n") @@ -338,6 +346,7 @@ func TestGroqStreamRejectsUnterminatedStream(t *testing.T) { } func TestGroqListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -370,6 +379,7 @@ func TestGroqListModelsAndCheckConnection(t *testing.T) { } func TestGroqBaseURLTrimsTrailingSlash(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -397,6 +407,7 @@ func TestGroqBaseURLTrimsTrailingSlash(t *testing.T) { } func TestGroqUsesEmptyRegionCustomBaseURL(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -429,6 +440,7 @@ func TestGroqUsesEmptyRegionCustomBaseURL(t *testing.T) { } func TestGroqUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newGroqForTest("http://unused") if _, err := m.Embed(ctx, nil, nil, nil, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") { diff --git a/internal/entity/models/huaweicloud.go b/internal/entity/models/huaweicloud.go index 2c23a1270b..a032463c45 100644 --- a/internal/entity/models/huaweicloud.go +++ b/internal/entity/models/huaweicloud.go @@ -37,7 +37,7 @@ func NewHuaweiCloudModel(baseURL map[string]string, urlSuffix URLSuffix) *Huawei baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/huaweicloud_test.go b/internal/entity/models/huaweicloud_test.go index 8c4d59cfc8..cc86a37fb0 100644 --- a/internal/entity/models/huaweicloud_test.go +++ b/internal/entity/models/huaweicloud_test.go @@ -19,6 +19,7 @@ package models import "testing" func TestHuaweiCloudToolCalls(t *testing.T) { + withSSRFBypass(t) newDriver := func(baseURL string) ModelDriver { return NewHuaweiCloudModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "v2/chat/completions"}) } diff --git a/internal/entity/models/huggingface.go b/internal/entity/models/huggingface.go index f5c674989c..bc3d5145be 100644 --- a/internal/entity/models/huggingface.go +++ b/internal/entity/models/huggingface.go @@ -37,7 +37,7 @@ func NewHuggingFaceModel(baseURL map[string]string, urlSuffix URLSuffix) *Huggin baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/hunyuan.go b/internal/entity/models/hunyuan.go index be0dbf0fd3..33d83c2da2 100644 --- a/internal/entity/models/hunyuan.go +++ b/internal/entity/models/hunyuan.go @@ -38,7 +38,7 @@ func NewHunyuanModel(baseURL map[string]string, urlSuffix URLSuffix) *HunyuanMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/hunyuan_test.go b/internal/entity/models/hunyuan_test.go index cdf2ec6305..dc3ba54e86 100644 --- a/internal/entity/models/hunyuan_test.go +++ b/internal/entity/models/hunyuan_test.go @@ -96,6 +96,7 @@ func TestHunyuanFactory(t *testing.T) { } func TestHunyuanChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "hunyuan-pro" { t.Errorf("model=%v", body["model"]) @@ -144,6 +145,7 @@ func TestHunyuanChatHappyPath(t *testing.T) { } func TestHunyuanChatNoReasoning(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "choices": []map[string]interface{}{{ @@ -173,6 +175,7 @@ func TestHunyuanChatNoReasoning(t *testing.T) { } func TestHunyuanChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newHunyuanForTest("http://unused").ChatWithMessages( ctx, @@ -186,6 +189,7 @@ func TestHunyuanChatRequiresAPIKey(t *testing.T) { } func TestHunyuanChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newHunyuanForTest("http://unused").ChatWithMessages( @@ -198,6 +202,7 @@ func TestHunyuanChatRequiresMessages(t *testing.T) { } func TestHunyuanChatPropagatesHTTPError(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"bad key"}`)) @@ -218,6 +223,7 @@ func TestHunyuanChatPropagatesHTTPError(t *testing.T) { } func TestHunyuanStreamHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ `data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+ @@ -258,6 +264,7 @@ func TestHunyuanStreamHappyPath(t *testing.T) { } func TestHunyuanStreamSplitsReasoning(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ `data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 1. "}}]}`+"\n"+ @@ -299,6 +306,7 @@ func TestHunyuanStreamSplitsReasoning(t *testing.T) { } func TestHunyuanStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" stream := false ctx := t.Context() @@ -316,6 +324,7 @@ func TestHunyuanStreamRejectsExplicitFalse(t *testing.T) { } func TestHunyuanStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newHunyuanForTest("http://unused").ChatStreamlyWithSender( @@ -329,6 +338,7 @@ func TestHunyuanStreamRequiresSender(t *testing.T) { } func TestHunyuanStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", ) @@ -348,6 +358,7 @@ func TestHunyuanStreamFailsWithoutTerminal(t *testing.T) { } func TestHunyuanStreamAcceptsTerminalWithoutDelta(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"finish_reason":"stop"}]}`+"\n\n", ) @@ -374,6 +385,7 @@ func TestHunyuanStreamAcceptsTerminalWithoutDelta(t *testing.T) { } func TestHunyuanStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ `data: {oops not json}`+"\n", @@ -394,6 +406,7 @@ func TestHunyuanStreamRejectsMalformedFrame(t *testing.T) { } func TestHunyuanStreamSurfacesUpstreamError(t *testing.T) { + withSSRFBypass(t) srv := newHunyuanSSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ `data: {"error":{"message":"rate limit","type":"rate_limit_error"}}`+"\n", @@ -417,6 +430,7 @@ func TestHunyuanStreamSurfacesUpstreamError(t *testing.T) { } func TestHunyuanListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -441,6 +455,7 @@ func TestHunyuanListModelsHappyPath(t *testing.T) { } func TestHunyuanListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newHunyuanForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -449,6 +464,7 @@ func TestHunyuanListModelsRequiresAPIKey(t *testing.T) { } func TestHunyuanCheckConnectionDelegatesToListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -464,6 +480,7 @@ func TestHunyuanCheckConnectionDelegatesToListModels(t *testing.T) { } func TestHunyuanCheckConnectionPropagatesError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -479,6 +496,7 @@ func TestHunyuanCheckConnectionPropagatesError(t *testing.T) { } func TestHunyuanBaseURLForRegionUnknown(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newHunyuanForTest("http://unused") apiKey := "test-key" @@ -490,6 +508,7 @@ func TestHunyuanBaseURLForRegionUnknown(t *testing.T) { } func TestHunyuanEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newHunyuanServer(t, http.MethodPost, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "hunyuan-embedding" { @@ -522,6 +541,7 @@ func TestHunyuanEmbedHappyPath(t *testing.T) { } func TestHunyuanEmbedValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := "hunyuan-embedding" @@ -549,6 +569,7 @@ func TestHunyuanEmbedValidatesInputs(t *testing.T) { } func TestHunyuanAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newHunyuanForTest("http://unused") model := "x" diff --git a/internal/entity/models/jiekouai.go b/internal/entity/models/jiekouai.go index 98631a5ca3..156f69c99e 100644 --- a/internal/entity/models/jiekouai.go +++ b/internal/entity/models/jiekouai.go @@ -36,7 +36,7 @@ func NewJieKouAIModel(baseURL map[string]string, urlSuffix URLSuffix) *JieKouAIM // JieKouAI's methods issue requests without a per-call context deadline, so // keep an explicit 120s client-level timeout to bound them. Built on the // shared transport via NewDriverHTTPClient. - client := NewDriverHTTPClient() + client := NewDriverHTTPClient(false) client.Timeout = 120 * time.Second return &JieKouAIModel{ baseModel: BaseModel{ diff --git a/internal/entity/models/jiekouai_test.go b/internal/entity/models/jiekouai_test.go index 8037502827..dca3a2a16d 100644 --- a/internal/entity/models/jiekouai_test.go +++ b/internal/entity/models/jiekouai_test.go @@ -65,6 +65,7 @@ func newJieKouAIForTest(baseURL string) *JieKouAIModel { } func TestJieKouAIChatForcesNonStreaming(t *testing.T) { + withSSRFBypass(t) srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { t.Errorf("method=%s, want POST", r.Method) @@ -109,6 +110,7 @@ func TestJieKouAIChatForcesNonStreaming(t *testing.T) { } func TestJieKouAIChatSendsExplicitThinkingFalse(t *testing.T) { + withSSRFBypass(t) srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["enable_thinking"] != false { t.Errorf("enable_thinking=%v, want false", body["enable_thinking"]) @@ -138,6 +140,7 @@ func TestJieKouAIChatSendsExplicitThinkingFalse(t *testing.T) { } func TestJieKouAIStreamForcesStreaming(t *testing.T) { + withSSRFBypass(t) srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/openai/v1/chat/completions" { t.Errorf("path=%s, want /openai/v1/chat/completions", r.URL.Path) @@ -190,6 +193,7 @@ func TestJieKouAIStreamForcesStreaming(t *testing.T) { } func TestJieKouAIListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -218,6 +222,7 @@ func TestJieKouAIListModelsHappyPath(t *testing.T) { } func TestJieKouAIListModelsRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" for name, response := range map[string]interface{}{ @@ -238,6 +243,7 @@ func TestJieKouAIListModelsRejectsMalformedResponse(t *testing.T) { } func TestJieKouAIEmbedSendsValidatedRequest(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/openai/v1/embeddings" { @@ -271,6 +277,7 @@ func TestJieKouAIEmbedSendsValidatedRequest(t *testing.T) { } func TestJieKouAIRerankHandlesNilConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/openai/v1/rerank" { @@ -306,6 +313,7 @@ func TestJieKouAIRerankHandlesNilConfig(t *testing.T) { } func TestJieKouAIValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" emptyKey := " " diff --git a/internal/entity/models/jina.go b/internal/entity/models/jina.go index fe2200f6ef..241e27e909 100644 --- a/internal/entity/models/jina.go +++ b/internal/entity/models/jina.go @@ -36,7 +36,7 @@ func NewJinaModel(baseURL map[string]string, urlSuffix URLSuffix) *JinaModel { // Embed/Rerank/ListModels issue requests without a per-call context // deadline, so keep an explicit 90s client-level timeout to bound them. // Built on the shared transport via NewDriverHTTPClient. - client := NewDriverHTTPClient() + client := NewDriverHTTPClient(false) client.Timeout = 90 * time.Second return &JinaModel{ baseModel: BaseModel{ diff --git a/internal/entity/models/jina_test.go b/internal/entity/models/jina_test.go index 24084a1790..f2648c50d7 100644 --- a/internal/entity/models/jina_test.go +++ b/internal/entity/models/jina_test.go @@ -51,6 +51,7 @@ func newJinaForTest(baseURL string) *JinaModel { } func TestJinaChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "jina-vlm" { @@ -91,6 +92,7 @@ func TestJinaChatHappyPath(t *testing.T) { } func TestJinaChatPreservesReasoningContent(t *testing.T) { + withSSRFBypass(t) srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "id": "jina-chat", @@ -129,6 +131,7 @@ func TestJinaChatSupportsToolCalls(t *testing.T) { } func TestJinaChatPropagatesConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["max_tokens"] != float64(128) { @@ -167,6 +170,7 @@ func TestJinaChatPropagatesConfig(t *testing.T) { } func TestJinaChatValidation(t *testing.T) { + withSSRFBypass(t) j := newJinaForTest("http://unused") apiKey := "test-key" emptyKey := "" @@ -224,6 +228,7 @@ func TestJinaChatValidation(t *testing.T) { } func TestJinaChatStreamIsNotSupported(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" err := newJinaForTest("http://unused").ChatStreamlyWithSender( t.Context(), @@ -240,6 +245,7 @@ func TestJinaChatStreamIsNotSupported(t *testing.T) { } func TestJinaEmbedMeanPoolsMultivectorResponse(t *testing.T) { + withSSRFBypass(t) srv := newJinaServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "data": []map[string]interface{}{{ @@ -269,6 +275,7 @@ func TestJinaEmbedMeanPoolsMultivectorResponse(t *testing.T) { } func TestJinaChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -285,6 +292,7 @@ func TestJinaChatRejectsHTTPError(t *testing.T) { } func TestJinaChatRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"choices": []map[string]interface{}{}}) @@ -300,6 +308,7 @@ func TestJinaChatRejectsMalformedResponse(t *testing.T) { } func TestJinaChatRejectsUnknownRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() j := newJinaForTest("http://unused") apiKey := "test-key" @@ -313,6 +322,7 @@ func TestJinaChatRejectsUnknownRegion(t *testing.T) { } func TestJinaChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -333,6 +343,7 @@ func TestJinaChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { } func TestJinaRerankDefaultsTopNToDocumentCount(t *testing.T) { + withSSRFBypass(t) srv := newJinaServer(t, "/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["top_n"] != float64(2) { t.Errorf("top_n=%v, want 2", body["top_n"]) diff --git a/internal/entity/models/lmstudio.go b/internal/entity/models/lmstudio.go index 30858da5f4..29b0d5ffaa 100644 --- a/internal/entity/models/lmstudio.go +++ b/internal/entity/models/lmstudio.go @@ -39,7 +39,7 @@ func NewLmStudioModel(baseURL map[string]string, urlSuffix URLSuffix) *LmStudioM BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/localai.go b/internal/entity/models/localai.go index 2445cfdd5f..a2e16e4800 100644 --- a/internal/entity/models/localai.go +++ b/internal/entity/models/localai.go @@ -44,7 +44,7 @@ func NewLocalAIModel(baseURL map[string]string, urlSuffix URLSuffix) *LocalAIMod BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/localai_test.go b/internal/entity/models/localai_test.go index cd573d51d0..b4d03d6544 100644 --- a/internal/entity/models/localai_test.go +++ b/internal/entity/models/localai_test.go @@ -43,6 +43,7 @@ func TestLocalAIName(t *testing.T) { } func TestLocalAIStreamCancelsOnIdle(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // The server emits one valid chunk and then stalls. Without the // watchdog, scanner.Scan() would hang forever. With the watchdog @@ -107,6 +108,7 @@ func TestLocalAIStreamCancelsOnIdle(t *testing.T) { } func TestLocalAIStreamCompletesWithoutTriggeringWatchdog(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Sanity check: a fast, complete stream should not trip the // watchdog even with a moderately tight idle window. @@ -149,6 +151,7 @@ func TestLocalAIStreamCompletesWithoutTriggeringWatchdog(t *testing.T) { } func TestLocalAIStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() l := newLocalAIForTest("http://unused") err := l.ChatStreamlyWithSender(ctx, "gpt-4", @@ -160,6 +163,7 @@ func TestLocalAIStreamRequiresSender(t *testing.T) { } func TestLocalAIChatMissingBaseURLFailsClearly(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // LocalAI has no public default; resolveBaseURL must fail with a // helpful message when neither the requested region nor "default" @@ -175,6 +179,7 @@ func TestLocalAIChatMissingBaseURLFailsClearly(t *testing.T) { } func TestLocalAIChatOmitsAuthHeaderWhenKeyEmpty(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Optional-auth contract: LocalAI accepts an empty key, so the // driver must NOT send a "Bearer " header in that case. @@ -200,6 +205,7 @@ func TestLocalAIChatOmitsAuthHeaderWhenKeyEmpty(t *testing.T) { } func TestLocalAIChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // And conversely: when a tenant has put LocalAI behind an auth // proxy with a token, the driver does send the Bearer header. @@ -223,6 +229,7 @@ func TestLocalAIChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { } func TestLocalAIBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() l := newLocalAIForTest("http://unused") _, err := l.Balance(ctx, &APIConfig{}) @@ -232,6 +239,7 @@ func TestLocalAIBalanceReturnsNoSuchMethod(t *testing.T) { } func TestLocalAIEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/embeddings" { @@ -259,6 +267,7 @@ func TestLocalAIEmbedHappyPath(t *testing.T) { } func TestLocalAIEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // CodeRabbit caught that a response repeating data[*].index would // silently overwrite the earlier vector. Verify the driver fails @@ -279,6 +288,7 @@ func TestLocalAIEmbedRejectsDuplicateIndex(t *testing.T) { } func TestLocalAIEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":7}]}`) @@ -294,6 +304,7 @@ func TestLocalAIEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestLocalAIEmbedRejectsMissingSlot(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":0}]}`) @@ -309,6 +320,7 @@ func TestLocalAIEmbedRejectsMissingSlot(t *testing.T) { } func TestLocalAIEmbedEmptyInputShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("Embed([]) made an unexpected HTTP call") @@ -372,6 +384,7 @@ func TestExtractLocalAIReasoning(t *testing.T) { // when proxied through OpenAI-shim). The driver must surface it on // ChatResponse.ReasonContent. func TestLocalAIChatExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"choices":[{"message":{ @@ -401,6 +414,7 @@ func TestLocalAIChatExtractsReasoningContent(t *testing.T) { // Non-streaming chat that uses message.thinking (Qwen3 via Ollama-shim // inside LocalAI). The driver must surface it on ReasonContent too. func TestLocalAIChatExtractsThinking(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"choices":[{"message":{ @@ -428,6 +442,7 @@ func TestLocalAIChatExtractsThinking(t *testing.T) { // non-reasoning model) must produce empty ReasonContent without // crashing or erroring. func TestLocalAIChatHandlesAbsentReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"choices":[{"message":{ @@ -456,6 +471,7 @@ func TestLocalAIChatHandlesAbsentReasoning(t *testing.T) { // chunks and delta.content chunks (kimi-k2.6, o-series shape). // Reasoning must reach the sender's 2nd arg, content the 1st. func TestLocalAIStreamExtractsReasoningContentDelta(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -502,6 +518,7 @@ func TestLocalAIStreamExtractsReasoningContentDelta(t *testing.T) { // Streaming chat where the upstream uses delta.thinking (Qwen3 shape). // The same handler must work. func TestLocalAIStreamExtractsThinkingDelta(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -541,6 +558,7 @@ func TestLocalAIStreamExtractsThinkingDelta(t *testing.T) { // Request-side: ChatConfig.Effort must flow into request body as // reasoning_effort. func TestLocalAIChatPropagatesReasoningEffort(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -576,6 +594,7 @@ func TestLocalAIChatPropagatesReasoningEffort(t *testing.T) { // Request-side: ChatConfig.Thinking must flow into request body as // enable_thinking (Qwen3-style). func TestLocalAIChatPropagatesEnableThinking(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -607,6 +626,7 @@ func TestLocalAIChatPropagatesEnableThinking(t *testing.T) { // Stream request also propagates the reasoning params. func TestLocalAIStreamPropagatesReasoningParams(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go index 28cee88fc7..275ca78dc5 100644 --- a/internal/entity/models/longcat.go +++ b/internal/entity/models/longcat.go @@ -38,7 +38,7 @@ func NewLongCatModel(baseURL map[string]string, urlSuffix URLSuffix) *LongCatMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/longcat_test.go b/internal/entity/models/longcat_test.go index 128dc17064..9814eee7aa 100644 --- a/internal/entity/models/longcat_test.go +++ b/internal/entity/models/longcat_test.go @@ -100,6 +100,7 @@ func TestLongCatNewModelWithCustomDefaultTransport(t *testing.T) { } func TestLongCatChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "LongCat-Flash-Chat" { @@ -136,6 +137,7 @@ func TestLongCatChatHappyPath(t *testing.T) { } func TestLongCatChatExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // LongCat-Flash-Thinking returns the chain-of-thought in // message.reasoning_content (OpenAI o-series shape). Live-probed @@ -180,6 +182,7 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) { // extracts the OpenAI-compatible usage block, including the nested // completion_tokens_details.reasoning_tokens from LongCat's thinking mode. func TestLongCatChatParsesUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -234,6 +237,7 @@ func TestLongCatChatParsesUsage(t *testing.T) { // thinking model can emit all output as reasoning_content, leaving content // null — that is a valid response, not an error. func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -289,6 +293,7 @@ func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) { // top_p — anything else is undocumented and must not be sent, since // the maintainer specifically flagged this on PR #14809. func TestLongCatChatDropsUndocumentedFields(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { for _, k := range []string{"stop", "reasoning_effort", "response_format", "tools", "tool_choice", "presence_penalty", "frequency_penalty", "n", "logprobs"} { @@ -333,6 +338,7 @@ func TestLongCatChatDropsUndocumentedFields(t *testing.T) { // requests aggregate usage via stream_options.include_usage and populates // chatConfig.UsageResult from the final usage event. func TestLongCatStreamRequestsIncludeUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { streamOpts, ok := body["stream_options"].(map[string]interface{}) @@ -373,6 +379,7 @@ func TestLongCatStreamRequestsIncludeUsage(t *testing.T) { // event carrying completion_tokens_details.reasoning_tokens populates // chatConfig.UsageResult. func TestLongCatStreamParsesReasoningTokens(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { streamOpts, ok := body["stream_options"].(map[string]interface{}) @@ -410,6 +417,7 @@ func TestLongCatStreamParsesReasoningTokens(t *testing.T) { } func TestLongCatChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") _, err := m.ChatWithMessages(ctx, "LongCat-Flash-Chat", @@ -422,6 +430,7 @@ func TestLongCatChatRequiresAPIKey(t *testing.T) { } func TestLongCatChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") apiKey := "test-key" @@ -432,6 +441,7 @@ func TestLongCatChatRequiresMessages(t *testing.T) { } func TestLongCatChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -450,6 +460,7 @@ func TestLongCatChatRejectsHTTPError(t *testing.T) { } func TestLongCatStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -489,6 +500,7 @@ func TestLongCatStreamHappyPath(t *testing.T) { } func TestLongCatStreamExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Fixture matches the shape captured live from // LongCat-Flash-Thinking against api.longcat.chat: deltas @@ -532,6 +544,7 @@ func TestLongCatStreamExtractsReasoningContent(t *testing.T) { } func TestLongCatStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") apiKey := "test-key" @@ -548,6 +561,7 @@ func TestLongCatStreamRejectsExplicitFalse(t *testing.T) { } func TestLongCatStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") apiKey := "test-key" @@ -560,6 +574,7 @@ func TestLongCatStreamRequiresSender(t *testing.T) { } func TestLongCatStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", @@ -581,6 +596,7 @@ func TestLongCatStreamFailsWithoutTerminal(t *testing.T) { // which masked truncated or corrupted streams. The driver must now // fail hard with a "longcat: invalid SSE event" wrapper. func TestLongCatStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ @@ -603,6 +619,7 @@ func TestLongCatStreamRejectsMalformedFrame(t *testing.T) { // the "no choices" continue and leave the caller with a generic // truncation error. The driver must surface the upstream error verbatim. func TestLongCatStreamSurfacesUpstreamError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newLongCatSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ @@ -625,6 +642,7 @@ func TestLongCatStreamSurfacesUpstreamError(t *testing.T) { } func TestLongCatListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var requests int srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { @@ -677,6 +695,7 @@ func TestLongCatListModelsAndCheckConnection(t *testing.T) { } func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() for name, response := range map[string]string{ "missing data": `{}`, @@ -699,6 +718,7 @@ func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) { } func TestLongCatListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() for name, cfg := range map[string]*APIConfig{ "nil config": nil, @@ -719,6 +739,7 @@ func TestLongCatListModelsRequiresAPIKey(t *testing.T) { } func TestLongCatEmbedReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") model := "x" @@ -729,6 +750,7 @@ func TestLongCatEmbedReturnsNoSuchMethod(t *testing.T) { } func TestLongCatRerankReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") model := "x" @@ -739,6 +761,7 @@ func TestLongCatRerankReturnsNoSuchMethod(t *testing.T) { } func TestLongCatBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") _, err := m.Balance(ctx, &APIConfig{}) @@ -748,6 +771,7 @@ func TestLongCatBalanceReturnsNoSuchMethod(t *testing.T) { } func TestLongCatAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newLongCatForTest("http://unused") model := "x" diff --git a/internal/entity/models/mineru.go b/internal/entity/models/mineru.go index c41a3ef6bd..403a391bd4 100644 --- a/internal/entity/models/mineru.go +++ b/internal/entity/models/mineru.go @@ -35,7 +35,7 @@ func NewMinerUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerUModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/mineru_local.go b/internal/entity/models/mineru_local.go index f478c7eeb8..18545c303a 100644 --- a/internal/entity/models/mineru_local.go +++ b/internal/entity/models/mineru_local.go @@ -37,7 +37,7 @@ func NewMinerLocalUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerU BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/minimax.go b/internal/entity/models/minimax.go index 1f4b2c0ba3..4dde6a5035 100644 --- a/internal/entity/models/minimax.go +++ b/internal/entity/models/minimax.go @@ -39,7 +39,7 @@ func NewMinimaxModel(baseURL map[string]string, urlSuffix URLSuffix) *MinimaxMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/minimax_test.go b/internal/entity/models/minimax_test.go index 5deff64f9e..0488927448 100644 --- a/internal/entity/models/minimax_test.go +++ b/internal/entity/models/minimax_test.go @@ -85,6 +85,7 @@ func TestMinimaxNewInstancePreservesConfig(t *testing.T) { } func TestMinimaxChatForcesNonStreaming(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { @@ -133,6 +134,7 @@ func TestMinimaxChatForcesNonStreaming(t *testing.T) { } func TestMinimaxChatRejectsEmptyChoices(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"choices": []map[string]interface{}{}}) @@ -154,6 +156,7 @@ func TestMinimaxChatRejectsEmptyChoices(t *testing.T) { } func TestMinimaxChatSurfacesBaseRespError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { // MiniMax rate-limit: HTTP 200 with base_resp, no choices. @@ -184,6 +187,7 @@ func TestMinimaxChatSurfacesBaseRespError(t *testing.T) { } func TestMinimaxChatSurfacesOpenAIError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusTooManyRequests) @@ -217,6 +221,7 @@ func TestMinimaxChatSurfacesOpenAIError(t *testing.T) { } func TestMinimaxStreamSurfacesBaseRespError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -244,6 +249,7 @@ func TestMinimaxStreamSurfacesBaseRespError(t *testing.T) { } func TestMinimaxStreamForcesStreaming(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { @@ -300,6 +306,7 @@ func TestMinimaxStreamForcesStreaming(t *testing.T) { } func TestMinimaxStreamAcceptsNilConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != true { @@ -326,6 +333,7 @@ func TestMinimaxStreamAcceptsNilConfig(t *testing.T) { } func TestMinimaxListModelsUsesBodylessGet(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -354,6 +362,7 @@ func TestMinimaxListModelsUsesBodylessGet(t *testing.T) { } func TestMinimaxListModelsRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" for name, response := range map[string]interface{}{ @@ -374,6 +383,7 @@ func TestMinimaxListModelsRejectsMalformedResponse(t *testing.T) { } func TestMinimaxCheckConnectionUsesListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -395,6 +405,7 @@ func TestMinimaxCheckConnectionUsesListModels(t *testing.T) { } func TestMinimaxValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" emptyKey := " " diff --git a/internal/entity/models/mistral.go b/internal/entity/models/mistral.go index 60a14d7241..40cf44eaee 100644 --- a/internal/entity/models/mistral.go +++ b/internal/entity/models/mistral.go @@ -45,7 +45,7 @@ func NewMistralModel(baseURL map[string]string, urlSuffix URLSuffix) *MistralMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/mistral_test.go b/internal/entity/models/mistral_test.go index 6d93998270..d01785cac8 100644 --- a/internal/entity/models/mistral_test.go +++ b/internal/entity/models/mistral_test.go @@ -68,6 +68,7 @@ func TestMistralName(t *testing.T) { } func TestMistralChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "mistral-large-latest" { @@ -106,6 +107,7 @@ func TestMistralChatHappyPath(t *testing.T) { } func TestMistralChatPropagatesConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["max_tokens"] != float64(64) { @@ -144,6 +146,7 @@ func TestMistralChatPropagatesConfig(t *testing.T) { } func TestMistralChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") _, err := m.ChatWithMessages(ctx, "mistral-large-latest", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil) @@ -158,6 +161,7 @@ func TestMistralChatRequiresAPIKey(t *testing.T) { } func TestMistralChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") apiKey := "test-key" @@ -168,6 +172,7 @@ func TestMistralChatRequiresMessages(t *testing.T) { } func TestMistralChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -184,6 +189,7 @@ func TestMistralChatRejectsHTTPError(t *testing.T) { } func TestMistralChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Empty *Region pointer must fall back to the "default" entry, not // be treated as an explicit "" region (which would miss the lookup). @@ -207,6 +213,7 @@ func TestMistralChatFallsBackToDefaultOnEmptyRegion(t *testing.T) { } func TestMistralListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}}) @@ -222,6 +229,7 @@ func TestMistralListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) { } func TestMistralStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") apiKey := "test-key" @@ -234,6 +242,7 @@ func TestMistralStreamRequiresSender(t *testing.T) { } func TestMistralChatRejectsUnknownRegion(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") apiKey := "test-key" @@ -247,6 +256,7 @@ func TestMistralChatRejectsUnknownRegion(t *testing.T) { } func TestMistralStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -302,6 +312,7 @@ func TestMistralStreamHappyPath(t *testing.T) { } func TestMistralStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") apiKey := "test-key" @@ -319,6 +330,7 @@ func TestMistralStreamRejectsExplicitFalse(t *testing.T) { } func TestMistralStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Body closes before [DONE] or a finish_reason -> driver must complain // instead of pretending the stream finished cleanly. @@ -341,6 +353,7 @@ func TestMistralStreamFailsWithoutTerminal(t *testing.T) { } func TestMistralListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -365,6 +378,7 @@ func TestMistralListModelsHappyPath(t *testing.T) { } func TestMistralListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") if _, err := m.ListModels(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -373,6 +387,7 @@ func TestMistralListModelsRequiresAPIKey(t *testing.T) { } func TestMistralCheckConnectionDelegatesToListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // 200 -> CheckConnection succeeds; 401 -> CheckConnection propagates. okSrv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { @@ -396,6 +411,7 @@ func TestMistralCheckConnectionDelegatesToListModels(t *testing.T) { } func TestMistralBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") _, err := m.Balance(ctx, &APIConfig{}) @@ -405,6 +421,7 @@ func TestMistralBalanceReturnsNoSuchMethod(t *testing.T) { } func TestMistralRerankReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") q := "mistral-large-latest" @@ -415,6 +432,7 @@ func TestMistralRerankReturnsNoSuchMethod(t *testing.T) { } func TestMistralTranscribeAudio(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() audioPath := filepath.Join(t.TempDir(), "sample.wav") if err := os.WriteFile(audioPath, []byte("fake-audio"), 0o600); err != nil { @@ -513,6 +531,7 @@ func TestMistralTranscribeAudio(t *testing.T) { } func TestMistralUnsupportedDefaultsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") modelName := "mistral-large-latest" @@ -549,6 +568,7 @@ func TestMistralUnsupportedDefaultsReturnNoSuchMethod(t *testing.T) { } func TestMistralEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "mistral-embed" { @@ -584,6 +604,7 @@ func TestMistralEmbedHappyPath(t *testing.T) { } func TestMistralEmbedReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Upstream returns the three vectors in shuffled order. The driver // must reorder them so the slot at position i corresponds to input i. @@ -613,6 +634,7 @@ func TestMistralEmbedReordersByIndex(t *testing.T) { } func TestMistralEmbedEmptyInputShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Empty input must NOT make an HTTP call; the test fails the request // rather than the assertion if it does. @@ -635,6 +657,7 @@ func TestMistralEmbedEmptyInputShortCircuits(t *testing.T) { } func TestMistralEmbedRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") model := "mistral-embed" @@ -645,6 +668,7 @@ func TestMistralEmbedRequiresAPIKey(t *testing.T) { } func TestMistralEmbedRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newMistralForTest("http://unused") apiKey := "test-key" @@ -660,6 +684,7 @@ func TestMistralEmbedRequiresModelName(t *testing.T) { } func TestMistralEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // A malformed upstream that repeats data[*].index would silently // overwrite the earlier vector; the driver must fail loudly instead. @@ -683,6 +708,7 @@ func TestMistralEmbedRejectsDuplicateIndex(t *testing.T) { } func TestMistralEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -703,6 +729,7 @@ func TestMistralEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestMistralEmbedRejectsMissingSlot(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Upstream returns only one of the two requested embeddings. srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { @@ -724,6 +751,7 @@ func TestMistralEmbedRejectsMissingSlot(t *testing.T) { } func TestMistralEmbedRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -745,6 +773,7 @@ func TestMistralEmbedRejectsHTTPError(t *testing.T) { // Regression net: the existing string-content path stays green for every // non-reasoning Mistral model. func TestMistralChatHandlesStringContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -780,6 +809,7 @@ func TestMistralChatHandlesStringContent(t *testing.T) { // from api.mistral.ai/v1/chat/completions against magistral-medium-latest // with the prompt "When do two trains meet?". func TestMistralChatExtractsReasoningFromStructuredContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -822,6 +852,7 @@ func TestMistralChatExtractsReasoningFromStructuredContent(t *testing.T) { // magistral with a trivial answer that needed no reasoning returns the // structured shape with only a `text` part. ReasonContent must be empty. func TestMistralChatHandlesStructuredContentWithoutThinking(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -855,6 +886,7 @@ func TestMistralChatHandlesStructuredContentWithoutThinking(t *testing.T) { // the parser forward-compatible with new Mistral content variants // (audio chunks, citations, etc.) that ragflow doesn't surface yet. func TestMistralChatIgnoresUnknownContentPartTypes(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/internal/entity/models/model_test.go b/internal/entity/models/model_test.go index f74ddb3039..90cfa9247b 100644 --- a/internal/entity/models/model_test.go +++ b/internal/entity/models/model_test.go @@ -235,6 +235,7 @@ func TestProviderConfigRejectsUnknownURLSuffixKey(t *testing.T) { } func TestPPIOProviderConfigLoadsIntoProviderManager(t *testing.T) { + withSSRFBypass(t) dir, restore := setupProviderTestDir(t, "ppio.json") defer restore() diff --git a/internal/entity/models/model_test_helpers_test.go b/internal/entity/models/model_test_helpers_test.go index 8e32defcf4..be97ba4d2e 100644 --- a/internal/entity/models/model_test_helpers_test.go +++ b/internal/entity/models/model_test_helpers_test.go @@ -3,8 +3,22 @@ package models import ( "strings" "testing" + + "ragflow/internal/utility" ) +// withSSRFBypass enables the test-only SSRF bypass for the duration of a test +// and restores the previous value in t.Cleanup. Driver tests exercise request +// and response logic against httptest servers bound to 127.0.0.1, which the +// strict SSRF guard (NewDriverHTTPClient(false)) rejects. SSRF enforcement has +// its own dedicated coverage in internal/utility/ssrf_test.go. +func withSSRFBypass(t *testing.T) { + t.Helper() + prev := utility.AllowAnyHostForTest + utility.AllowAnyHostForTest = true + t.Cleanup(func() { utility.AllowAnyHostForTest = prev }) +} + func requireNoSuchMethod(t *testing.T, name string, err error) { t.Helper() if err == nil { diff --git a/internal/entity/models/modelscope.go b/internal/entity/models/modelscope.go index d1da4bc676..c07b743c64 100644 --- a/internal/entity/models/modelscope.go +++ b/internal/entity/models/modelscope.go @@ -61,7 +61,7 @@ func NewModelScopeModel(baseURL map[string]string, urlSuffix URLSuffix) *ModelSc BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/modelscope_test.go b/internal/entity/models/modelscope_test.go index 704909b6f1..bec6da6a10 100644 --- a/internal/entity/models/modelscope_test.go +++ b/internal/entity/models/modelscope_test.go @@ -94,6 +94,7 @@ func TestModelScopeNewModelWithCustomDefaultTransport(t *testing.T) { } func TestModelScopeChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -146,6 +147,7 @@ func TestModelScopeChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) } func TestModelScopeChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer ms-test" { @@ -166,6 +168,7 @@ func TestModelScopeChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { } func TestModelScopeChatExtractsReasoningFields(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"choices":[{"message":{ @@ -188,6 +191,7 @@ func TestModelScopeChatExtractsReasoningFields(t *testing.T) { } func TestModelScopeStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { @@ -252,6 +256,7 @@ func TestModelScopeStreamHappyPath(t *testing.T) { } func TestModelScopeStreamRejectsFalseStreamConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newModelScopeForTest("http://unused") stream := false @@ -267,6 +272,7 @@ func TestModelScopeStreamRejectsFalseStreamConfig(t *testing.T) { } func TestModelScopeStreamCancelsOnIdle(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() withModelScopeIdleTimeout(t, 200*time.Millisecond) @@ -297,6 +303,7 @@ func TestModelScopeStreamCancelsOnIdle(t *testing.T) { } func TestModelScopeListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { @@ -325,6 +332,7 @@ func TestModelScopeListModelsAndCheckConnection(t *testing.T) { } func TestModelScopeMissingBaseURLFailsClearly(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := NewModelScopeModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"}) _, err := m.ChatWithMessages(ctx, "Qwen/Qwen2.5-7B-Instruct", @@ -336,6 +344,7 @@ func TestModelScopeMissingBaseURLFailsClearly(t *testing.T) { } func TestModelScopeUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newModelScopeForTest("http://unused") model := "Qwen/Qwen2.5-7B-Instruct" diff --git a/internal/entity/models/moonshot.go b/internal/entity/models/moonshot.go index 4e34d4f8c9..b38586ff2d 100644 --- a/internal/entity/models/moonshot.go +++ b/internal/entity/models/moonshot.go @@ -38,7 +38,7 @@ func NewMoonshotModel(baseURL map[string]string, urlSuffix URLSuffix) *MoonshotM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/moonshot_test.go b/internal/entity/models/moonshot_test.go index 483bc6c970..244fb28ef0 100644 --- a/internal/entity/models/moonshot_test.go +++ b/internal/entity/models/moonshot_test.go @@ -85,6 +85,7 @@ func TestMoonshotNewInstancePreservesConfig(t *testing.T) { } func TestMoonshotChatForcesNonStreaming(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { @@ -132,6 +133,7 @@ func TestMoonshotChatForcesNonStreaming(t *testing.T) { } func TestMoonshotChatSupportsTools(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { tools, ok := body["tools"].([]interface{}) @@ -185,6 +187,7 @@ func TestMoonshotChatSupportsTools(t *testing.T) { } func TestMoonshotChatParsesCompletionSchema(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -250,6 +253,7 @@ func TestMoonshotChatParsesCompletionSchema(t *testing.T) { } func TestMoonshotStreamForcesStreaming(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodPost { @@ -314,6 +318,7 @@ func TestMoonshotStreamForcesStreaming(t *testing.T) { } func TestMoonshotStreamDoesNotSendDoneAfterScannerError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != true { @@ -349,6 +354,7 @@ func TestMoonshotStreamDoesNotSendDoneAfterScannerError(t *testing.T) { } func TestMoonshotListModelsUsesBodylessGet(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -377,6 +383,7 @@ func TestMoonshotListModelsUsesBodylessGet(t *testing.T) { } func TestMoonshotBalanceUsesBodylessGet(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -411,6 +418,7 @@ func TestMoonshotBalanceUsesBodylessGet(t *testing.T) { } func TestMoonshotRejectsMalformedResponses(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" tests := []struct { @@ -463,6 +471,7 @@ func TestMoonshotRejectsMalformedResponses(t *testing.T) { } func TestMoonshotValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" emptyKey := " " diff --git a/internal/entity/models/n1n.go b/internal/entity/models/n1n.go index 6ae530ae76..6207ce5716 100644 --- a/internal/entity/models/n1n.go +++ b/internal/entity/models/n1n.go @@ -38,7 +38,7 @@ func NewN1NModel(baseURL map[string]string, urlSuffix URLSuffix) *N1NModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/non_chat_usage_test.go b/internal/entity/models/non_chat_usage_test.go index e260c20012..aa46038238 100644 --- a/internal/entity/models/non_chat_usage_test.go +++ b/internal/entity/models/non_chat_usage_test.go @@ -8,6 +8,7 @@ import ( ) func TestProviderEmbeddingAndRerankUsage(t *testing.T) { + withSSRFBypass(t) type providerCase struct { name string newModel func(string) ModelDriver diff --git a/internal/entity/models/novita.go b/internal/entity/models/novita.go index fb75cc6274..839b6206d2 100644 --- a/internal/entity/models/novita.go +++ b/internal/entity/models/novita.go @@ -39,7 +39,7 @@ func NewNovitaModel(baseURL map[string]string, urlSuffix URLSuffix) *NovitaModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/novita_test.go b/internal/entity/models/novita_test.go index b1a2eda284..9bcf17c736 100644 --- a/internal/entity/models/novita_test.go +++ b/internal/entity/models/novita_test.go @@ -255,6 +255,7 @@ func TestNovitaName(t *testing.T) { } func TestNovitaChatPureText(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -280,6 +281,7 @@ func TestNovitaChatPureText(t *testing.T) { } func TestNovitaChatExtractsThinkTags(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // qwen3-style response: ... embedded in content. // Driver must split it into Answer + ReasonContent. @@ -318,6 +320,7 @@ func TestNovitaChatExtractsThinkTags(t *testing.T) { // to ChatResponse.ReasonContent. Live-confirmed against // api.novita.ai/openai/v1/chat/completions with deepseek/deepseek-v3.1. func TestNovitaChatExtractsReasoningContentField(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -356,6 +359,7 @@ func TestNovitaChatExtractsReasoningContentField(t *testing.T) { // (not delta.content with tags). The driver must forward // those chunks via the sender's second arg. func TestNovitaStreamExtractsDeltaReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"step 1. "}}]}`+"\n"+ @@ -401,6 +405,7 @@ func TestNovitaStreamExtractsDeltaReasoningContent(t *testing.T) { // so a tenant can switch a deepseek-v3.1 / glm-4.5 / qwen3 deployment // out of its default thinking mode without prompt-level hacks. func TestNovitaChatPropagatesEnableThinking(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() cases := []struct { name string @@ -446,6 +451,7 @@ func TestNovitaChatPropagatesEnableThinking(t *testing.T) { // silently flip behavior for downstream proxies that distinguish // "field absent" from "field present with default". Leave it out. func TestNovitaChatOmitsEnableThinkingWhenUnset(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if _, present := body["enable_thinking"]; present { @@ -473,6 +479,7 @@ func TestNovitaChatOmitsEnableThinkingWhenUnset(t *testing.T) { // case for ChatStreamlyWithSender so callers get the same toggle // regardless of streaming mode. func TestNovitaStreamPropagatesEnableThinking(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -505,6 +512,7 @@ func TestNovitaStreamPropagatesEnableThinking(t *testing.T) { } func TestNovitaChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newNovitaForTest("http://unused").ChatWithMessages(ctx, "m", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil) @@ -514,6 +522,7 @@ func TestNovitaChatRequiresAPIKey(t *testing.T) { } func TestNovitaChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newNovitaForTest("http://unused").ChatWithMessages(ctx, "m", nil, @@ -524,6 +533,7 @@ func TestNovitaChatRequiresMessages(t *testing.T) { } func TestNovitaChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -544,6 +554,7 @@ func TestNovitaChatRejectsHTTPError(t *testing.T) { // delta.content must surface reasoning chunks through the sender's // second arg, and visible content through the first. func TestNovitaStreamSplitsThinkTags(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Simulate the realistic case where tags span deltas — split // "" across two chunks, and split "" too. @@ -595,6 +606,7 @@ func TestNovitaStreamSplitsThinkTags(t *testing.T) { // Streaming for a non-reasoning model that emits only content chunks // must continue to work unchanged. func TestNovitaStreamPureContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaSSEServer(t, "/openai/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -633,6 +645,7 @@ func TestNovitaStreamPureContent(t *testing.T) { } func TestNovitaStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newNovitaForTest("http://unused").ChatStreamlyWithSender(ctx, "m", @@ -644,6 +657,7 @@ func TestNovitaStreamRequiresSender(t *testing.T) { } func TestNovitaStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -659,6 +673,7 @@ func TestNovitaStreamRejectsExplicitFalse(t *testing.T) { } func TestNovitaListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -682,6 +697,7 @@ func TestNovitaListModelsHappyPath(t *testing.T) { } func TestNovitaCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}}) @@ -695,6 +711,7 @@ func TestNovitaCheckConnection(t *testing.T) { } func TestNovitaRerankHappyPathReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "baai/bge-reranker-v2-m3" { @@ -732,6 +749,7 @@ func TestNovitaRerankHappyPathReordersByIndex(t *testing.T) { } func TestNovitaRerankRespectsTopNConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["top_n"] != float64(2) { @@ -749,6 +767,7 @@ func TestNovitaRerankRespectsTopNConfig(t *testing.T) { } func TestNovitaRerankEmptyDocumentsShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := "x" @@ -762,6 +781,7 @@ func TestNovitaRerankEmptyDocumentsShortCircuits(t *testing.T) { } func TestNovitaRerankRequiresApiKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := "x" _, err := newNovitaForTest("http://unused").Rerank(ctx, &model, "q", []string{"a"}, &APIConfig{}, nil, nil) @@ -771,6 +791,7 @@ func TestNovitaRerankRequiresApiKey(t *testing.T) { } func TestNovitaRerankRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newNovitaForTest("http://unused").Rerank(ctx, nil, "q", []string{"a"}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -780,6 +801,7 @@ func TestNovitaRerankRequiresModelName(t *testing.T) { } func TestNovitaRerankRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _, _ = io.WriteString(w, `{"results":[{"index":5,"relevance_score":0.5}]}`) @@ -795,6 +817,7 @@ func TestNovitaRerankRejectsOutOfRangeIndex(t *testing.T) { } func TestNovitaRerankRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _, _ = io.WriteString(w, `{"results":[{"index":0,"relevance_score":0.9},{"index":0,"relevance_score":0.5}]}`) @@ -810,6 +833,7 @@ func TestNovitaRerankRejectsDuplicateIndex(t *testing.T) { } func TestNovitaRerankSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) @@ -826,6 +850,7 @@ func TestNovitaRerankSurfacesHTTPError(t *testing.T) { } func TestNovitaRerankRejectsMissingRerankSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := "x" @@ -840,6 +865,7 @@ func TestNovitaRerankRejectsMissingRerankSuffix(t *testing.T) { } func TestNovitaBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Balance IS implemented (makes HTTP call), not a "no such method" stub. // With dummy key it should reach the HTTP call stage, not fail at APIConfigCheck. @@ -851,6 +877,7 @@ func TestNovitaBalanceReturnsNoSuchMethod(t *testing.T) { } func TestNovitaAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := "x" apiKey := "test-key" @@ -873,6 +900,7 @@ func TestNovitaAudioOCRReturnNoSuchMethod(t *testing.T) { // slash. baseURLForRegion now trims the trailing "/" so all three // endpoint builders (Chat, Stream, ListModels) emit clean paths. func TestNovitaBaseURLTrimsTrailingSlash(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() cases := []struct { name string diff --git a/internal/entity/models/nvidia.go b/internal/entity/models/nvidia.go index 333b89da75..ab10b38de4 100644 --- a/internal/entity/models/nvidia.go +++ b/internal/entity/models/nvidia.go @@ -49,7 +49,7 @@ func NewNvidiaModel(baseURL map[string]string, urlSuffix URLSuffix) *NvidiaModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, catalogURL: nvidiaCatalogURL, hostedAPIHost: nvidiaHostedAPIHost, diff --git a/internal/entity/models/nvidia_rerank_test.go b/internal/entity/models/nvidia_rerank_test.go index ad3f95ddfb..9e7a35397c 100644 --- a/internal/entity/models/nvidia_rerank_test.go +++ b/internal/entity/models/nvidia_rerank_test.go @@ -52,6 +52,7 @@ func newNvidiaModelForTest(baseURL string) *NvidiaModel { } func TestNvidiaRerankHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "nvidia/nv-rerankqa-mistral-4b-v3" { @@ -110,6 +111,7 @@ func TestNvidiaRerankHappyPath(t *testing.T) { } func TestNvidiaRerankTopNClamp(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["top_n"] != float64(2) { @@ -135,6 +137,7 @@ func TestNvidiaRerankTopNClamp(t *testing.T) { } func TestNvidiaRerankEmptyDocuments(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := newNvidiaModelForTest("http://unused") apiKey := "test-key" @@ -149,6 +152,7 @@ func TestNvidiaRerankEmptyDocuments(t *testing.T) { } func TestNvidiaRerankRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := newNvidiaModelForTest("http://unused") modelName := "nvidia/nv-rerankqa-mistral-4b-v3" @@ -159,6 +163,7 @@ func TestNvidiaRerankRequiresAPIKey(t *testing.T) { } func TestNvidiaRerankRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := newNvidiaModelForTest("http://unused") apiKey := "test-key" @@ -169,6 +174,7 @@ func TestNvidiaRerankRequiresModelName(t *testing.T) { } func TestNvidiaRerankRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -186,6 +192,7 @@ func TestNvidiaRerankRejectsHTTPError(t *testing.T) { } func TestNvidiaRerankRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/internal/entity/models/nvidia_test.go b/internal/entity/models/nvidia_test.go index 8896b88b42..961c5f1a80 100644 --- a/internal/entity/models/nvidia_test.go +++ b/internal/entity/models/nvidia_test.go @@ -12,6 +12,7 @@ import ( ) func TestNvidiaListModelsUsesExactEndpointIDs(t *testing.T) { + withSSRFBypass(t) const apiKey = "nvapi-test" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -99,6 +100,7 @@ func TestParseNvidiaModelListInfersTypesForPresetWithoutTypes(t *testing.T) { } func TestNvidiaListModelsFiltersHostedCatalog(t *testing.T) { + withSSRFBypass(t) const apiKey = "nvapi-test" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -142,6 +144,7 @@ func TestNvidiaListModelsFiltersHostedCatalog(t *testing.T) { } func TestNvidiaFetchHostedCatalogPaginates(t *testing.T) { + withSSRFBypass(t) resources := make([]nvidiaCatalogResource, nvidiaCatalogPageSize+1) for i := range resources { resources[i] = nvidiaCatalogResource{DisplayName: fmt.Sprintf("model-%d", i)} @@ -192,6 +195,7 @@ func TestNvidiaFetchHostedCatalogPaginates(t *testing.T) { } func TestNvidiaListModelsRejectsPartialHostedCatalog(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/models" { _ = json.NewEncoder(w).Encode(ModelList{Models: []ModelListItem{{ID: "meta/llama-3.1-8b-instruct"}}}) diff --git a/internal/entity/models/ollama.go b/internal/entity/models/ollama.go index fd188a31bd..f72d440d9a 100644 --- a/internal/entity/models/ollama.go +++ b/internal/entity/models/ollama.go @@ -40,7 +40,7 @@ func NewOllamaModel(baseURL map[string]string, urlSuffix URLSuffix) *OllamaModel BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/ollama_test.go b/internal/entity/models/ollama_test.go index daf65d9e69..786ad13f40 100644 --- a/internal/entity/models/ollama_test.go +++ b/internal/entity/models/ollama_test.go @@ -28,6 +28,7 @@ func newOllamaForListModelsTest(baseURL string) *OllamaModel { } func TestOllamaListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -54,6 +55,7 @@ func TestOllamaListModels(t *testing.T) { } func TestOllamaListModelsFallsBackToModelField(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Some entries may carry only the "model" field; it should be used as the name. @@ -74,6 +76,7 @@ func TestOllamaListModelsFallsBackToModelField(t *testing.T) { } func TestOllamaListModelsRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -87,6 +90,7 @@ func TestOllamaListModelsRejectsHTTPError(t *testing.T) { } func TestOllamaListModelsRequiresBaseURL(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := NewOllamaModel(map[string]string{}, URLSuffix{Models: "api/tags"}) if _, err := m.ListModels(ctx, &APIConfig{}); err == nil { diff --git a/internal/entity/models/openai.go b/internal/entity/models/openai.go index b8166d7341..b6cb75d066 100644 --- a/internal/entity/models/openai.go +++ b/internal/entity/models/openai.go @@ -44,7 +44,7 @@ func NewOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenAIModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/openai_api_compatible.go b/internal/entity/models/openai_api_compatible.go index 78b60c30f4..5a0ee13c9a 100644 --- a/internal/entity/models/openai_api_compatible.go +++ b/internal/entity/models/openai_api_compatible.go @@ -326,7 +326,7 @@ func (m *OpenAIAPICompatibleModel) TranscribeAudio(ctx context.Context, modelNam var result struct { Text string `json:"text"` } - if err := json.Unmarshal(respBody, &result); err != nil { + if err = json.Unmarshal(respBody, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w, body=%s", err, string(respBody)) } diff --git a/internal/entity/models/openai_compatible_usage_test.go b/internal/entity/models/openai_compatible_usage_test.go index 79b7476514..11b8d8980b 100644 --- a/internal/entity/models/openai_compatible_usage_test.go +++ b/internal/entity/models/openai_compatible_usage_test.go @@ -25,6 +25,7 @@ import ( ) func TestOpenAICompatibleProvidersExtractStreamingUsage(t *testing.T) { + withSSRFBypass(t) providers := []struct { name string new func(string) ModelDriver diff --git a/internal/entity/models/openai_test.go b/internal/entity/models/openai_test.go index a717c29810..bb71848e35 100644 --- a/internal/entity/models/openai_test.go +++ b/internal/entity/models/openai_test.go @@ -68,6 +68,7 @@ func TestOpenAIConfigAdvertisedAudioModelsHaveSuffixes(t *testing.T) { } func TestOpenAITranscribeAudioPostsMultipartToAudioEndpoint(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -146,6 +147,7 @@ func TestOpenAITranscribeAudioPostsMultipartToAudioEndpoint(t *testing.T) { } func TestOpenAITranscribeAudioWithSenderStreamsDeltas(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -214,6 +216,7 @@ func TestOpenAITranscribeAudioWithSenderStreamsDeltas(t *testing.T) { } func TestOpenAIAudioSpeechPostsJSONToAudioEndpoint(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -281,6 +284,7 @@ func TestOpenAIAudioSpeechPostsJSONToAudioEndpoint(t *testing.T) { } func TestOpenAIAudioSpeechRequiresVoice(t *testing.T) { + withSSRFBypass(t) apiKey := "test-key" model := "tts-1" input := "hello" @@ -300,6 +304,7 @@ func TestOpenAIAudioSpeechRequiresVoice(t *testing.T) { } func TestOpenAIAudioSpeechRejectsNonStringVoice(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := "tts-1" @@ -319,6 +324,7 @@ func TestOpenAIAudioSpeechRejectsNonStringVoice(t *testing.T) { } func TestOpenAIAudioSpeechWithSenderStreamsRawAudio(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method=%s, want POST", r.Method) @@ -383,6 +389,7 @@ func TestOpenAIAudioSpeechWithSenderStreamsRawAudio(t *testing.T) { } func TestOpenAIAudioSpeechWithSenderStreamsSSEAudioDeltas(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Accept"); got != "text/event-stream" { diff --git a/internal/entity/models/openrouter.go b/internal/entity/models/openrouter.go index da26203c64..e573467dfa 100644 --- a/internal/entity/models/openrouter.go +++ b/internal/entity/models/openrouter.go @@ -41,7 +41,7 @@ func NewOpenRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenRou baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/openrouter_test.go b/internal/entity/models/openrouter_test.go index e3c51f695a..7abc7a2452 100644 --- a/internal/entity/models/openrouter_test.go +++ b/internal/entity/models/openrouter_test.go @@ -77,6 +77,7 @@ func TestOpenRouterAudioFormatUsesConfiguredValue(t *testing.T) { } func TestOpenRouterTranscribeAudioHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() audio := []byte("RIFF test audio") srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { @@ -143,6 +144,7 @@ func TestOpenRouterTranscribeAudioHappyPath(t *testing.T) { } func TestOpenRouterTranscribeAudioInfersFormat(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { inputAudio, ok := body["input_audio"].(map[string]interface{}) @@ -167,6 +169,7 @@ func TestOpenRouterTranscribeAudioInfersFormat(t *testing.T) { } func TestOpenRouterTranscribeAudioValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() modelName := "openai/whisper-large-v3" apiKey := "test-key" @@ -195,6 +198,7 @@ func TestOpenRouterTranscribeAudioValidatesInputs(t *testing.T) { } func TestOpenRouterTranscribeAudioValidatesASRSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "openai/whisper-large-v3" @@ -208,6 +212,7 @@ func TestOpenRouterTranscribeAudioValidatesASRSuffix(t *testing.T) { } func TestOpenRouterTranscribeAudioHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) @@ -231,6 +236,7 @@ func TestOpenRouterTranscribeAudioHTTPError(t *testing.T) { // and reasoning is requested via OpenRouter's standard `reasoning` object rather // than the non-standard `thinking` key that the API silently ignores. func TestOpenRouterChatStreamlyRequest(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var gotPath string var gotBody map[string]interface{} diff --git a/internal/entity/models/orcarouter.go b/internal/entity/models/orcarouter.go index 3270d4622d..aaf887236a 100644 --- a/internal/entity/models/orcarouter.go +++ b/internal/entity/models/orcarouter.go @@ -35,7 +35,7 @@ func NewOrcaRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OrcaRou baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/paddleocr.go b/internal/entity/models/paddleocr.go index 251f0d15d8..69a0562a2f 100644 --- a/internal/entity/models/paddleocr.go +++ b/internal/entity/models/paddleocr.go @@ -40,7 +40,7 @@ func NewPaddleOCRModel(baseURL map[string]string, urlSuffix URLSuffix) *PaddleOC BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/paddleocr_local.go b/internal/entity/models/paddleocr_local.go index d14cf9bf46..e195db04fb 100644 --- a/internal/entity/models/paddleocr_local.go +++ b/internal/entity/models/paddleocr_local.go @@ -37,7 +37,7 @@ func NewPaddleOCRLocalModel(baseURL map[string]string, urlSuffix URLSuffix) *Pad baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/perplexity.go b/internal/entity/models/perplexity.go index 9292b7c58f..428e6cea8e 100644 --- a/internal/entity/models/perplexity.go +++ b/internal/entity/models/perplexity.go @@ -36,7 +36,7 @@ func NewPerplexityModel(baseURL map[string]string, urlSuffix URLSuffix) *Perplex baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/perplexity_test.go b/internal/entity/models/perplexity_test.go index 77cfca0804..b596280991 100644 --- a/internal/entity/models/perplexity_test.go +++ b/internal/entity/models/perplexity_test.go @@ -60,6 +60,7 @@ func TestPerplexityFactory(t *testing.T) { } func TestPerplexityChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/sonar" { @@ -111,6 +112,7 @@ func TestPerplexityChatHappyPath(t *testing.T) { } func TestPerplexityChatSkipsReasoningEffortForNonReasoningModel(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "sonar" { @@ -148,6 +150,7 @@ func TestPerplexityChatSkipsReasoningEffortForNonReasoningModel(t *testing.T) { } func TestPerplexityChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newPerplexityForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -157,6 +160,7 @@ func TestPerplexityChatRequiresModelName(t *testing.T) { } func TestPerplexityChatRequiresApiKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newPerplexityForTest("http://unused").ChatWithMessages(ctx, "sonar", []Message{{Role: "user", Content: "x"}}, nil, nil, nil) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -165,6 +169,7 @@ func TestPerplexityChatRequiresApiKey(t *testing.T) { } func TestPerplexityStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/sonar" { @@ -215,6 +220,7 @@ func TestPerplexityStreamHappyPath(t *testing.T) { } func TestPerplexityStreamStopsOnDoneMarker(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -248,6 +254,7 @@ func TestPerplexityStreamStopsOnDoneMarker(t *testing.T) { } func TestPerplexityListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -281,6 +288,7 @@ func TestPerplexityListModelsAndCheckConnection(t *testing.T) { } func TestPerplexityListModelsAcceptsBareArray(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode([]map[string]interface{}{ @@ -301,6 +309,7 @@ func TestPerplexityListModelsAcceptsBareArray(t *testing.T) { } func TestPerplexityEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/v1/embeddings" { @@ -350,6 +359,7 @@ func TestPerplexityEmbedHappyPath(t *testing.T) { } func TestPerplexityEmbedEmptyTextsReturnsEmpty(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() modelName := "pplx-embed-v1-0.6b" apiKey := "test-key" @@ -363,6 +373,7 @@ func TestPerplexityEmbedEmptyTextsReturnsEmpty(t *testing.T) { } func TestPerplexityEmbedRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newPerplexityForTest("http://unused").Embed(ctx, nil, []string{"x"}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -372,6 +383,7 @@ func TestPerplexityEmbedRequiresModelName(t *testing.T) { } func TestPerplexityUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newPerplexityForTest("http://unused") if _, err := m.Rerank(ctx, nil, "", nil, nil, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") { diff --git a/internal/entity/models/ppio.go b/internal/entity/models/ppio.go index cc2034c454..8867f8c41a 100644 --- a/internal/entity/models/ppio.go +++ b/internal/entity/models/ppio.go @@ -39,7 +39,7 @@ func NewPPIOModel(baseURL map[string]string, urlSuffix URLSuffix) *PPIOModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/ppio_test.go b/internal/entity/models/ppio_test.go index 4b248abacd..6293e55e05 100644 --- a/internal/entity/models/ppio_test.go +++ b/internal/entity/models/ppio_test.go @@ -76,6 +76,7 @@ func TestPPIONewModelWithCustomDefaultTransport(t *testing.T) { } func TestPPIOChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -162,6 +163,7 @@ func TestPPIOChatHappyPath(t *testing.T) { } func TestPPIOChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newPPIOForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -171,6 +173,7 @@ func TestPPIOChatRequiresModelName(t *testing.T) { } func TestPPIOChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newPPIOForTest("http://unused").ChatWithMessages(ctx, "deepseek/deepseek-r1", nil, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -180,6 +183,7 @@ func TestPPIOChatRequiresMessages(t *testing.T) { } func TestPPIOChatSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { http.Error(w, "bad key", http.StatusUnauthorized) @@ -194,6 +198,7 @@ func TestPPIOChatSurfacesHTTPError(t *testing.T) { } func TestPPIOChatRejectsProviderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -210,6 +215,7 @@ func TestPPIOChatRejectsProviderError(t *testing.T) { } func TestPPIOStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -276,6 +282,7 @@ func TestPPIOStreamHappyPath(t *testing.T) { } func TestPPIOEmbedRecordsUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/embeddings" { @@ -315,6 +322,7 @@ func TestPPIOEmbedRecordsUsage(t *testing.T) { } func TestPPIORerankRecordsUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/rerank" { @@ -355,6 +363,7 @@ func TestPPIORerankRecordsUsage(t *testing.T) { } func TestPPIOStreamSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { http.Error(w, "bad key", http.StatusUnauthorized) @@ -375,6 +384,7 @@ func TestPPIOStreamSurfacesHTTPError(t *testing.T) { } func TestPPIOStreamStopsOnSenderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -396,6 +406,7 @@ func TestPPIOStreamStopsOnSenderError(t *testing.T) { } func TestPPIOStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -414,6 +425,7 @@ func TestPPIOStreamRejectsExplicitFalse(t *testing.T) { } func TestPPIOStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newPPIOForTest("http://unused").ChatStreamlyWithSender( @@ -428,6 +440,7 @@ func TestPPIOStreamRequiresSender(t *testing.T) { } func TestPPIOStreamRequiresTerminalEvent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -449,6 +462,7 @@ func TestPPIOStreamRequiresTerminalEvent(t *testing.T) { } func TestPPIOListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -481,6 +495,7 @@ func TestPPIOListModelsAndCheckConnection(t *testing.T) { } func TestPPIOListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newPPIOForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -489,6 +504,7 @@ func TestPPIOListModelsRequiresAPIKey(t *testing.T) { } func TestPPIOListModelsRejectsProviderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -568,6 +584,7 @@ func TestPPIOMissingRegionBaseURL(t *testing.T) { } func TestPPIOUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newPPIOForTest("http://unused") if _, err := m.Balance(ctx, nil); err == nil || !strings.Contains(err.Error(), "no such method") { diff --git a/internal/entity/models/provider_response_usage_test.go b/internal/entity/models/provider_response_usage_test.go index cca2e6b1b6..3e3cd5a9db 100644 --- a/internal/entity/models/provider_response_usage_test.go +++ b/internal/entity/models/provider_response_usage_test.go @@ -24,6 +24,7 @@ import ( ) func TestProviderLocalChatResponsesExposeUsage(t *testing.T) { + withSSRFBypass(t) type providerCase struct { name string path string diff --git a/internal/entity/models/qiniu.go b/internal/entity/models/qiniu.go index 11e70e299e..d0d87eab83 100644 --- a/internal/entity/models/qiniu.go +++ b/internal/entity/models/qiniu.go @@ -37,7 +37,7 @@ func NewQiniuModel(baseURL map[string]string, urlSuffix URLSuffix) *QiniuModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/qiniu_test.go b/internal/entity/models/qiniu_test.go index 0181d913ab..a69313bd8d 100644 --- a/internal/entity/models/qiniu_test.go +++ b/internal/entity/models/qiniu_test.go @@ -24,6 +24,7 @@ import ( ) func TestQiniuToolCalls(t *testing.T) { + withSSRFBypass(t) newDriver := func(baseURL string) ModelDriver { return NewQiniuModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"}) } @@ -36,6 +37,7 @@ func TestQiniuToolCalls(t *testing.T) { } func TestQiniuChatStreamRejectsTruncatedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -52,6 +54,7 @@ func TestQiniuChatStreamRejectsTruncatedResponse(t *testing.T) { } func TestQiniuCheckConnectionUsesListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -75,6 +78,7 @@ func TestQiniuCheckConnectionUsesListModels(t *testing.T) { } func TestQiniuCheckConnectionPropagatesListModelsError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, `{"message":"invalid api key"}`, http.StatusUnauthorized) diff --git a/internal/entity/models/ragcon.go b/internal/entity/models/ragcon.go index cad87c130d..7910c67226 100644 --- a/internal/entity/models/ragcon.go +++ b/internal/entity/models/ragcon.go @@ -45,7 +45,7 @@ func NewRAGconModel(baseURL map[string]string, urlSuffix URLSuffix) *RAGconModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/ragcon_test.go b/internal/entity/models/ragcon_test.go index 04b9414008..bd41323038 100644 --- a/internal/entity/models/ragcon_test.go +++ b/internal/entity/models/ragcon_test.go @@ -82,6 +82,7 @@ func TestRAGconFactory(t *testing.T) { } func TestRAGconChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { @@ -133,6 +134,7 @@ func TestRAGconChatHappyPath(t *testing.T) { } func TestRAGconChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newRAGconForTest("http://unused").ChatWithMessages( ctx, @@ -147,6 +149,7 @@ func TestRAGconChatRequiresAPIKey(t *testing.T) { } func TestRAGconChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newRAGconForTest("http://unused").ChatWithMessages( @@ -162,6 +165,7 @@ func TestRAGconChatRequiresMessages(t *testing.T) { } func TestRAGconChatSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -183,6 +187,7 @@ func TestRAGconChatSurfacesHTTPError(t *testing.T) { } func TestRAGconStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -239,6 +244,7 @@ func TestRAGconStreamHappyPath(t *testing.T) { } func TestRAGconStreamRejectsNilSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newRAGconForTest("http://unused").ChatStreamlyWithSender( @@ -256,6 +262,7 @@ func TestRAGconStreamRejectsNilSender(t *testing.T) { } func TestRAGconEmbed(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/embeddings" { @@ -282,6 +289,7 @@ func TestRAGconEmbed(t *testing.T) { } func TestRAGconRerank(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/rerank" { @@ -311,6 +319,7 @@ func TestRAGconRerank(t *testing.T) { } func TestRAGconListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/models" { @@ -335,6 +344,7 @@ func TestRAGconListModelsAndCheckConnection(t *testing.T) { } func TestRAGconTranscribeAudioPostsMultipart(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/audio/transcriptions" { @@ -379,6 +389,7 @@ func TestRAGconTranscribeAudioPostsMultipart(t *testing.T) { } func TestRAGconAudioSpeechPostsJSON(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/audio/speech" { @@ -404,6 +415,7 @@ func TestRAGconAudioSpeechPostsJSON(t *testing.T) { } func TestRAGconUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() r := newRAGconForTest("http://unused") model := "llama-4-maverick" diff --git a/internal/entity/models/reasoning_family_provider_test.go b/internal/entity/models/reasoning_family_provider_test.go index 37a9b21aaf..89dc3bdf28 100644 --- a/internal/entity/models/reasoning_family_provider_test.go +++ b/internal/entity/models/reasoning_family_provider_test.go @@ -47,6 +47,7 @@ func newReasoningFamilyChatServer(t *testing.T, handler func(t *testing.T, body } func TestGiteeChatExtractsQwenThinkingFromInlineContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "qwen3-8b" { @@ -87,6 +88,7 @@ func TestGiteeChatExtractsQwenThinkingFromInlineContent(t *testing.T) { } func TestSiliconflowChatExtractsProviderPrefixedQwenThinkingFromInlineContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "qwen/qwen3-8b" { diff --git a/internal/entity/models/replicate.go b/internal/entity/models/replicate.go index 22f7be9327..c75a33cc55 100644 --- a/internal/entity/models/replicate.go +++ b/internal/entity/models/replicate.go @@ -42,7 +42,7 @@ func NewReplicateModel(baseURL map[string]string, urlSuffix URLSuffix) *Replicat baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/replicate_test.go b/internal/entity/models/replicate_test.go index 489b86b33d..d4e80bbdbc 100644 --- a/internal/entity/models/replicate_test.go +++ b/internal/entity/models/replicate_test.go @@ -75,6 +75,7 @@ func TestReplicatePredictionEndpoint(t *testing.T) { } func TestReplicateOfficialChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models/meta/meta-llama-3-70b-instruct/predictions" { t.Errorf("path=%s", r.URL.Path) @@ -143,6 +144,7 @@ func TestReplicateOfficialChatHappyPath(t *testing.T) { } func TestReplicateCommunityChatUsesVersionEndpoint(t *testing.T) { + withSSRFBypass(t) const version = "replicate/hello-world:5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/predictions" { @@ -180,6 +182,7 @@ func TestReplicateCommunityChatUsesVersionEndpoint(t *testing.T) { } func TestReplicateChatPollsUntilSucceeded(t *testing.T) { + withSSRFBypass(t) var getCount int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer test-key" { @@ -223,6 +226,7 @@ func TestReplicateChatPollsUntilSucceeded(t *testing.T) { } func TestReplicateStreamHappyPath(t *testing.T) { + withSSRFBypass(t) var streamURL string streamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Accept"); got != "text/event-stream" { @@ -283,6 +287,7 @@ func TestReplicateStreamHappyPath(t *testing.T) { } func TestReplicateListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { @@ -315,6 +320,7 @@ func TestReplicateListModelsAndCheckConnection(t *testing.T) { } func TestReplicateUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newReplicateForTest("http://unused") apiKey := "test-key" diff --git a/internal/entity/models/siliconflow.go b/internal/entity/models/siliconflow.go index ff85cabc83..c760c7d06c 100644 --- a/internal/entity/models/siliconflow.go +++ b/internal/entity/models/siliconflow.go @@ -42,7 +42,7 @@ func NewSiliconflowModel(baseURL map[string]string, urlSuffix URLSuffix) *Silico baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/siliconflow_test.go b/internal/entity/models/siliconflow_test.go index 3623e61ca3..f87cdb12f4 100644 --- a/internal/entity/models/siliconflow_test.go +++ b/internal/entity/models/siliconflow_test.go @@ -25,6 +25,7 @@ import ( ) func TestSiliconflowToolCalls(t *testing.T) { + withSSRFBypass(t) newDriver := func(baseURL string) ModelDriver { return NewSiliconflowModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"}) } @@ -37,6 +38,7 @@ func TestSiliconflowToolCalls(t *testing.T) { } func TestSiliconflowChatRejectsMissingContent(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"choices":[{"message":{}}]}`)) })) @@ -51,6 +53,7 @@ func TestSiliconflowChatRejectsMissingContent(t *testing.T) { } func TestSiliconflowChatWithMessagesExtractsResponseAndUsage(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-siliconflow", @@ -109,6 +112,7 @@ func TestSiliconflowChatWithMessagesExtractsResponseAndUsage(t *testing.T) { } func TestSiliconflowChatStreamlyWithSenderCollectsUsage(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requestBody map[string]any if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { diff --git a/internal/entity/models/sse_scanner_buffer_test.go b/internal/entity/models/sse_scanner_buffer_test.go index 89a2b1410b..566807d395 100644 --- a/internal/entity/models/sse_scanner_buffer_test.go +++ b/internal/entity/models/sse_scanner_buffer_test.go @@ -35,6 +35,7 @@ func largeSSEStreamServer(t *testing.T, content string) *httptest.Server { } func TestChatStreamLargeChunkNotTruncated(t *testing.T) { + withSSRFBypass(t) // 128KB content delta: comfortably past the 64KB default so the bare // scanner would fail, well under the 1MB raised cap so the fix succeeds. const big = 128 * 1024 diff --git a/internal/entity/models/stepfun.go b/internal/entity/models/stepfun.go index bbfa3990f1..f4f863708f 100644 --- a/internal/entity/models/stepfun.go +++ b/internal/entity/models/stepfun.go @@ -39,7 +39,7 @@ func NewStepFunModel(baseURL map[string]string, urlSuffix URLSuffix) *StepFunMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/stepfun_test.go b/internal/entity/models/stepfun_test.go index 6e3cac97f0..e3b4b4bd80 100644 --- a/internal/entity/models/stepfun_test.go +++ b/internal/entity/models/stepfun_test.go @@ -114,6 +114,7 @@ func TestStepFunNewInstancePreservesConfig(t *testing.T) { } func TestStepFunChatParsesUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -161,6 +162,7 @@ func TestStepFunChatParsesUsage(t *testing.T) { } func TestStepFunChatParsesExtendedUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -214,6 +216,7 @@ func TestStepFunChatParsesExtendedUsage(t *testing.T) { } func TestStepFunChatFallsBackTotalTokens(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -254,6 +257,7 @@ func TestStepFunChatFallsBackTotalTokens(t *testing.T) { } func TestStepFunChatNilUsageWhenAllZero(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -292,6 +296,7 @@ func TestStepFunChatNilUsageWhenAllZero(t *testing.T) { } func TestStepFunChatExtractsReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -334,6 +339,7 @@ func TestStepFunChatExtractsReasoning(t *testing.T) { } func TestStepFunChatFallsBackToReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -376,6 +382,7 @@ func TestStepFunChatFallsBackToReasoningContent(t *testing.T) { } func TestStepFunChatAcceptsReasoningOnlyResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -421,6 +428,7 @@ func TestStepFunChatAcceptsReasoningOnlyResponse(t *testing.T) { } func TestStepFunStreamParsesUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+ @@ -448,6 +456,7 @@ func TestStepFunStreamParsesUsage(t *testing.T) { } func TestStepFunStreamNullUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // StepFun may send usage: null on intermediate chunks. srv := newStepFunSSEServer(t, "/v1/chat/completions", @@ -476,6 +485,7 @@ func TestStepFunStreamNullUsage(t *testing.T) { } func TestStepFunStreamExtractsReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -516,6 +526,7 @@ func TestStepFunStreamExtractsReasoning(t *testing.T) { } func TestStepFunStreamFallsBackToReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"reasoning_content":"ds-style reasoning"}}]}`+"\n"+ @@ -546,6 +557,7 @@ func TestStepFunStreamFallsBackToReasoningContent(t *testing.T) { } func TestStepFunStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -585,6 +597,7 @@ func TestStepFunStreamHappyPath(t *testing.T) { } func TestStepFunStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") apiKey := "test-key" @@ -597,6 +610,7 @@ func TestStepFunStreamRequiresSender(t *testing.T) { } func TestStepFunStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", @@ -615,6 +629,7 @@ func TestStepFunStreamFailsWithoutTerminal(t *testing.T) { } func TestStepFunChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") _, err := m.ChatWithMessages(ctx, "step-3.7-flash", @@ -626,6 +641,7 @@ func TestStepFunChatRequiresAPIKey(t *testing.T) { } func TestStepFunChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") apiKey := "test-key" @@ -636,6 +652,7 @@ func TestStepFunChatRequiresMessages(t *testing.T) { } func TestStepFunChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -654,6 +671,7 @@ func TestStepFunChatRejectsHTTPError(t *testing.T) { } func TestStepFunChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "step-3.7-flash" { @@ -704,6 +722,7 @@ func TestStepFunChatHappyPath(t *testing.T) { } func TestStepFunChatSupportsToolCalls(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var requestBody map[string]interface{} srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { @@ -783,6 +802,7 @@ func TestStepFunChatSupportsToolCalls(t *testing.T) { } func TestStepFunStreamDoesNotSendDoneAfterScannerError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: `+strings.Repeat("x", 1024*1024+1)+"\n", @@ -810,6 +830,7 @@ func TestStepFunStreamDoesNotSendDoneAfterScannerError(t *testing.T) { } func TestStepFunChatRejectsMalformedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -833,6 +854,7 @@ func TestStepFunChatRejectsMalformedResponse(t *testing.T) { } func TestStepFunStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunSSEServer(t, "/v1/chat/completions", `data: {"choices":[{"index":0,"delta":{"content":"ok"}}]}`+"\n"+ @@ -852,6 +874,7 @@ func TestStepFunStreamRejectsMalformedFrame(t *testing.T) { } func TestStepFunListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -887,6 +910,7 @@ func TestStepFunListModelsAndCheckConnection(t *testing.T) { } func TestStepFunListModelsRejectsInvalidResponses(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newStepFunServer(t, "/v1/models", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -905,6 +929,7 @@ func TestStepFunListModelsRejectsInvalidResponses(t *testing.T) { } func TestStepFunListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") _, err := m.ListModels(ctx, &APIConfig{}) @@ -914,6 +939,7 @@ func TestStepFunListModelsRequiresAPIKey(t *testing.T) { } func TestStepFunEmbedReturnsNotImplemented(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") model := "x" @@ -924,6 +950,7 @@ func TestStepFunEmbedReturnsNotImplemented(t *testing.T) { } func TestStepFunRerankReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") model := "x" @@ -934,6 +961,7 @@ func TestStepFunRerankReturnsNoSuchMethod(t *testing.T) { } func TestStepFunBalanceReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") _, err := m.Balance(ctx, &APIConfig{}) @@ -943,6 +971,7 @@ func TestStepFunBalanceReturnsNoSuchMethod(t *testing.T) { } func TestStepFunAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newStepFunForTest("http://unused") model := "x" diff --git a/internal/entity/models/timeout_test.go b/internal/entity/models/timeout_test.go index d41e3bbcfc..f2b66e70f2 100644 --- a/internal/entity/models/timeout_test.go +++ b/internal/entity/models/timeout_test.go @@ -59,6 +59,7 @@ func newTimeoutTestGroq(baseURL string) *GroqModel { // nonStreamCallTimeout (the old 120s-wall footgun, just relocated), the // context would cancel mid-stream and this test would fail. func TestStreamNotTruncatedByNonStreamTimeout(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Stream emits for ~240ms, far past the 60ms non-stream deadline but well // inside the 10s stream deadline. @@ -118,6 +119,7 @@ func TestStreamNotTruncatedByNonStreamTimeout(t *testing.T) { // watchdog so a broken timeout surfaces as a direct test failure // rather than relying on the package's global test timeout. func TestNonStreamHonorsShortDeadline(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() withTestTimeouts(t, 100*time.Millisecond, 10*time.Second) diff --git a/internal/entity/models/togetherai.go b/internal/entity/models/togetherai.go index b6ca986f6b..5511958fc1 100644 --- a/internal/entity/models/togetherai.go +++ b/internal/entity/models/togetherai.go @@ -41,7 +41,7 @@ func NewTogetherAIModel(baseURL map[string]string, urlSuffix URLSuffix) *Togethe baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/togetherai_test.go b/internal/entity/models/togetherai_test.go index 6ea82d6717..7e648cc0f9 100644 --- a/internal/entity/models/togetherai_test.go +++ b/internal/entity/models/togetherai_test.go @@ -60,6 +60,7 @@ func TestTogetherAIFactory(t *testing.T) { } func TestTogetherAIChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -111,6 +112,7 @@ func TestTogetherAIChatHappyPath(t *testing.T) { } func TestTogetherAIChatForwardsReasoningEnabled(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "Qwen/Qwen3.5-9B" { @@ -155,6 +157,7 @@ func TestTogetherAIChatForwardsReasoningEnabled(t *testing.T) { } func TestTogetherAIChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newTogetherAIForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -164,6 +167,7 @@ func TestTogetherAIChatRequiresModelName(t *testing.T) { } func TestTogetherAIStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.URL.Path != "/chat/completions" { @@ -214,6 +218,7 @@ func TestTogetherAIStreamHappyPath(t *testing.T) { } func TestTogetherAIStreamStopsOnRootFinishReason(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -246,6 +251,7 @@ func TestTogetherAIStreamStopsOnRootFinishReason(t *testing.T) { } func TestTogetherAIListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -276,6 +282,7 @@ func TestTogetherAIListModelsAndCheckConnection(t *testing.T) { } func TestTogetherAIUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newTogetherAIForTest("http://unused") apiKey := "test-key" diff --git a/internal/entity/models/tokenhub.go b/internal/entity/models/tokenhub.go index 6524f49806..3887ada534 100644 --- a/internal/entity/models/tokenhub.go +++ b/internal/entity/models/tokenhub.go @@ -36,7 +36,7 @@ func NewTokenHubModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenHubM baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/tokenhub_test.go b/internal/entity/models/tokenhub_test.go index e91068d580..457797bb28 100644 --- a/internal/entity/models/tokenhub_test.go +++ b/internal/entity/models/tokenhub_test.go @@ -98,6 +98,7 @@ func TestTokenHubFactory(t *testing.T) { } func TestTokenHubChatWithMessagesForcesNonStreaming(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenHubServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != false { @@ -136,6 +137,7 @@ func TestTokenHubChatWithMessagesForcesNonStreaming(t *testing.T) { } func TestTokenHubChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newTokenHubForTest("http://unused").ChatWithMessages(ctx, "gpt-4o-mini", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -144,6 +146,7 @@ func TestTokenHubChatRequiresAPIKey(t *testing.T) { } func TestTokenHubChatRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newTokenHubForTest("http://unused").ChatWithMessages(ctx, " ", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil) @@ -153,6 +156,7 @@ func TestTokenHubChatRequiresModelName(t *testing.T) { } func TestTokenHubStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenHubSSEServer(t, "/chat/completions", strings.Join([]string{ `data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}`, @@ -194,6 +198,7 @@ func TestTokenHubStreamHappyPath(t *testing.T) { } func TestTokenHubStreamRejectsFalseStreamConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -212,6 +217,7 @@ func TestTokenHubStreamRejectsFalseStreamConfig(t *testing.T) { } func TestTokenHubStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newTokenHubForTest("http://unused").ChatStreamlyWithSender( @@ -229,6 +235,7 @@ func TestTokenHubStreamRequiresSender(t *testing.T) { } func TestTokenHubStreamRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() err := newTokenHubForTest("http://unused").ChatStreamlyWithSender( ctx, @@ -245,6 +252,7 @@ func TestTokenHubStreamRequiresAPIKey(t *testing.T) { } func TestTokenHubStreamRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newTokenHubForTest("http://unused").ChatStreamlyWithSender( @@ -262,6 +270,7 @@ func TestTokenHubStreamRequiresModelName(t *testing.T) { } func TestTokenHubEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenHubServer(t, http.MethodPost, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "text-embedding-3-small" { @@ -294,6 +303,7 @@ func TestTokenHubEmbedHappyPath(t *testing.T) { } func TestTokenHubEmbedValidatesInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" if embeddings, err := newTokenHubForTest("http://unused").Embed(ctx, nil, nil, &APIConfig{ApiKey: &apiKey}, nil, nil); err != nil || len(embeddings) != 0 { @@ -309,6 +319,7 @@ func TestTokenHubEmbedValidatesInputs(t *testing.T) { } func TestTokenHubListModelsHappyPathSkipsMalformedItems(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenHubServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -334,6 +345,7 @@ func TestTokenHubListModelsHappyPathSkipsMalformedItems(t *testing.T) { } func TestTokenHubListModelsValidatesResponseAndAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() if _, err := newTokenHubForTest("http://unused").ListModels(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "api key is required") { t.Fatalf("expected api-key error, got %v", err) diff --git a/internal/entity/models/tokenpony.go b/internal/entity/models/tokenpony.go index d87dda67fc..28b3bea594 100644 --- a/internal/entity/models/tokenpony.go +++ b/internal/entity/models/tokenpony.go @@ -38,7 +38,7 @@ func NewTokenPonyModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenPon baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/tokenpony_test.go b/internal/entity/models/tokenpony_test.go index d8b3674faf..a4cbd89581 100644 --- a/internal/entity/models/tokenpony_test.go +++ b/internal/entity/models/tokenpony_test.go @@ -92,6 +92,7 @@ func TestTokenPonyFactory(t *testing.T) { } func TestTokenPonyChatHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "qwen3-32b" { @@ -140,6 +141,7 @@ func TestTokenPonyChatHappyPath(t *testing.T) { } func TestTokenPonyChatNoReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -168,6 +170,7 @@ func TestTokenPonyChatNoReasoning(t *testing.T) { } func TestTokenPonyChatRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newTokenPonyForTest("http://unused").ChatWithMessages( ctx, @@ -180,6 +183,7 @@ func TestTokenPonyChatRequiresAPIKey(t *testing.T) { } func TestTokenPonyChatRequiresMessages(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" _, err := newTokenPonyForTest("http://unused").ChatWithMessages( @@ -190,6 +194,7 @@ func TestTokenPonyChatRequiresMessages(t *testing.T) { } func TestTokenPonyChatPropagatesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -209,6 +214,7 @@ func TestTokenPonyChatPropagatesHTTPError(t *testing.T) { } func TestTokenPonyStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonySSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -249,6 +255,7 @@ func TestTokenPonyStreamHappyPath(t *testing.T) { } func TestTokenPonyStreamSplitsReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonySSEServer(t, "/chat/completions", `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ @@ -290,6 +297,7 @@ func TestTokenPonyStreamSplitsReasoning(t *testing.T) { } func TestTokenPonyStreamRejectsExplicitFalse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" stream := false @@ -307,6 +315,7 @@ func TestTokenPonyStreamRejectsExplicitFalse(t *testing.T) { } func TestTokenPonyStreamRequiresSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" err := newTokenPonyForTest("http://unused").ChatStreamlyWithSender( @@ -320,6 +329,7 @@ func TestTokenPonyStreamRequiresSender(t *testing.T) { } func TestTokenPonyStreamFailsWithoutTerminal(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonySSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", @@ -339,6 +349,7 @@ func TestTokenPonyStreamFailsWithoutTerminal(t *testing.T) { } func TestTokenPonyStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonySSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ @@ -359,6 +370,7 @@ func TestTokenPonyStreamRejectsMalformedFrame(t *testing.T) { } func TestTokenPonyStreamSurfacesUpstreamError(t *testing.T) { + withSSRFBypass(t) srv := newTokenPonySSEServer(t, "/chat/completions", `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ `data: {"error":{"message":"rate limit","type":"rate_limit_error"}}`+"\n", @@ -382,6 +394,7 @@ func TestTokenPonyStreamSurfacesUpstreamError(t *testing.T) { } func TestTokenPonyListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -406,6 +419,7 @@ func TestTokenPonyListModelsHappyPath(t *testing.T) { } func TestTokenPonyListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newTokenPonyForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -414,6 +428,7 @@ func TestTokenPonyListModelsRequiresAPIKey(t *testing.T) { } func TestTokenPonyCheckConnectionDelegatesToListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -429,6 +444,7 @@ func TestTokenPonyCheckConnectionDelegatesToListModels(t *testing.T) { } func TestTokenPonyCheckConnectionPropagatesError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -444,6 +460,7 @@ func TestTokenPonyCheckConnectionPropagatesError(t *testing.T) { } func TestTokenPonyBaseURLForRegionUnknown(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newTokenPonyForTest("http://unused") apiKey := "test-key" @@ -455,6 +472,7 @@ func TestTokenPonyBaseURLForRegionUnknown(t *testing.T) { } func TestTokenPonyEmbedReturnsNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := "x" _, err := newTokenPonyForTest("http://unused").Embed(ctx, &model, []string{"a"}, &APIConfig{}, nil, nil) @@ -464,6 +482,7 @@ func TestTokenPonyEmbedReturnsNoSuchMethod(t *testing.T) { } func TestTokenPonyAudioOCRReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newTokenPonyForTest("http://unused") model := "x" diff --git a/internal/entity/models/tool_call_test.go b/internal/entity/models/tool_call_test.go index c13a536dbb..056c5a90be 100644 --- a/internal/entity/models/tool_call_test.go +++ b/internal/entity/models/tool_call_test.go @@ -24,6 +24,7 @@ import ( ) func testNonStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) { + withSSRFBypass(t) ctx := t.Context() t.Helper() var requestBody map[string]interface{} @@ -67,6 +68,7 @@ func testNonStreamingToolCall(t *testing.T, modelName, path string, newDriver fu } func testStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) { + withSSRFBypass(t) ctx := t.Context() t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/entity/models/upstage.go b/internal/entity/models/upstage.go index 7044c744e9..b89f577825 100644 --- a/internal/entity/models/upstage.go +++ b/internal/entity/models/upstage.go @@ -38,7 +38,7 @@ func NewUpstageModel(baseURL map[string]string, urlSuffix URLSuffix) *UpstageMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/upstage_test.go b/internal/entity/models/upstage_test.go index 86d5df3cd0..ebfe158161 100644 --- a/internal/entity/models/upstage_test.go +++ b/internal/entity/models/upstage_test.go @@ -23,6 +23,7 @@ func newUpstageForTest(baseURL string) *UpstageModel { // ---------- reasoning_effort / reasoning field ---------- func TestUpstageChatPropagatesReasoningEffort(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Per https://console.upstage.ai/api/docs/for-agents/raw, Upstage // Solar models accept `reasoning_effort: minimal|low|medium|high`. @@ -52,6 +53,7 @@ func TestUpstageChatPropagatesReasoningEffort(t *testing.T) { } func TestUpstageChatOmitsReasoningEffortWhenUnset(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // If the caller does not opt in, the field must NOT be sent. Sending // "minimal" by default would silently change behavior for downstream @@ -81,6 +83,7 @@ func TestUpstageChatOmitsReasoningEffortWhenUnset(t *testing.T) { } func TestUpstageStreamPropagatesReasoningEffort(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -113,6 +116,7 @@ func TestUpstageStreamPropagatesReasoningEffort(t *testing.T) { } func TestUpstageChatExtractsReasoningField(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Per the Upstage docs: when reasoning_effort is high|medium for // solar-pro3 (or high for solar-pro2), the response's @@ -143,6 +147,7 @@ func TestUpstageChatExtractsReasoningField(t *testing.T) { } func TestUpstageChatHandlesAbsentReasoning(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Models without reasoning (solar-mini, syn-pro) or low-effort // requests return no `reasoning` field. The driver must leave @@ -172,6 +177,7 @@ func TestUpstageChatHandlesAbsentReasoning(t *testing.T) { // https://console.upstage.ai/api/chat) round-trips through the request // body for both streaming and non-streaming paths. func TestUpstageRequestBodyMatchesSolarAPIShape(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -221,6 +227,7 @@ func TestUpstageRequestBodyMatchesSolarAPIShape(t *testing.T) { // ---------- Embed: duplicate / out-of-range / reorder ---------- func TestUpstageEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // A malformed upstream that repeats data[*].index would silently // overwrite the earlier vector; the driver must fail loudly instead. @@ -241,6 +248,7 @@ func TestUpstageEmbedRejectsDuplicateIndex(t *testing.T) { } func TestUpstageEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":7}]}`) @@ -257,6 +265,7 @@ func TestUpstageEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestUpstageEmbedHappyPathReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // Upstream returns vectors in shuffled order; driver must realign. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -290,6 +299,7 @@ func TestUpstageEmbedHappyPathReordersByIndex(t *testing.T) { // reasoning_effort=high — both fields appear, sometimes in the same // chunk and sometimes separately. func TestUpstageStreamExtractsReasoningDelta(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -348,6 +358,7 @@ func TestUpstageStreamExtractsReasoningDelta(t *testing.T) { // present: reasoning is forwarded first so a UI consuming both can // render the chain-of-thought before the answer for that token. func TestUpstageStreamReasoningChunksArriveBeforeContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -396,6 +407,7 @@ func TestUpstageStreamReasoningChunksArriveBeforeContent(t *testing.T) { // non-reasoning models (solar-mini, solar-pro2 with no reasoning_effort) // emit only delta.content. The driver must not regress on them. func TestUpstageStreamWithoutReasoningStillWorks(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/entity/models/vllm.go b/internal/entity/models/vllm.go index efd81366a6..f7bbef29c9 100644 --- a/internal/entity/models/vllm.go +++ b/internal/entity/models/vllm.go @@ -39,7 +39,7 @@ func NewVllmModel(baseURL map[string]string, urlSuffix URLSuffix) *VllmModel { BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/vllm_rerank_test.go b/internal/entity/models/vllm_rerank_test.go index d7d4f3db12..e4733bdb8c 100644 --- a/internal/entity/models/vllm_rerank_test.go +++ b/internal/entity/models/vllm_rerank_test.go @@ -50,6 +50,7 @@ func newVllmModelForTest(baseURL string) *VllmModel { } func TestVllmRerankHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "BAAI/bge-reranker-v2-m3" { @@ -109,6 +110,7 @@ func TestVllmRerankHappyPath(t *testing.T) { } func TestVllmRerankTopNClamp(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["top_n"] != float64(2) { @@ -134,6 +136,7 @@ func TestVllmRerankTopNClamp(t *testing.T) { } func TestVllmRerankEmptyDocuments(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := newVllmModelForTest("http://unused") apiKey := "test-key" @@ -151,6 +154,7 @@ func TestVllmRerankEmptyDocuments(t *testing.T) { // no APIConfig.ApiKey is configured. This diverges from the NVIDIA driver // which requires an API key. func TestVllmRerankWithoutAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVllmRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -173,6 +177,7 @@ func TestVllmRerankWithoutAPIKey(t *testing.T) { } func TestVllmRerankRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() model := newVllmModelForTest("http://unused") apiKey := "test-key" @@ -183,6 +188,7 @@ func TestVllmRerankRequiresModelName(t *testing.T) { } func TestVllmRerankRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusInternalServerError) @@ -200,6 +206,7 @@ func TestVllmRerankRejectsHTTPError(t *testing.T) { } func TestVllmRerankRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/internal/entity/models/volcengine.go b/internal/entity/models/volcengine.go index 013c1eed1b..12e64cdd14 100644 --- a/internal/entity/models/volcengine.go +++ b/internal/entity/models/volcengine.go @@ -39,7 +39,7 @@ func NewVolcEngine(baseURL map[string]string, urlSuffix URLSuffix) *VolcEngine { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/volcengine_test.go b/internal/entity/models/volcengine_test.go index d2e88a75f0..1863fb2da1 100644 --- a/internal/entity/models/volcengine_test.go +++ b/internal/entity/models/volcengine_test.go @@ -56,6 +56,7 @@ func TestVolcEngineConfigDeclaresModelsSuffix(t *testing.T) { } func TestVolcEngineListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) { if r.Method != http.MethodGet { @@ -89,6 +90,7 @@ func TestVolcEngineListModelsHappyPath(t *testing.T) { } func TestVolcEngineListModelsRejectsProviderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) { http.Error(w, "bad key", http.StatusUnauthorized) @@ -103,6 +105,7 @@ func TestVolcEngineListModelsRejectsProviderError(t *testing.T) { } func TestVolcEngineListModelsRequiresModelsSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" model := NewVolcEngine(map[string]string{"default": "http://unused"}, URLSuffix{}) @@ -114,6 +117,7 @@ func TestVolcEngineListModelsRequiresModelsSuffix(t *testing.T) { } func TestVolcEngineChatStreamSupportsMaxEffortAndUsage(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) { var body map[string]interface{} @@ -155,6 +159,7 @@ func TestVolcEngineChatStreamSupportsMaxEffortAndUsage(t *testing.T) { } func TestVolcEngineChatStreamRejectsTruncatedResponse(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVolcEngineServer(t, func(t *testing.T, _ *http.Request, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/entity/models/voyage.go b/internal/entity/models/voyage.go index 25d1a86db2..fce90b515c 100644 --- a/internal/entity/models/voyage.go +++ b/internal/entity/models/voyage.go @@ -38,7 +38,7 @@ func NewVoyageModel(baseURL map[string]string, urlSuffix URLSuffix) *VoyageModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/voyage_test.go b/internal/entity/models/voyage_test.go index 3b0b255ff5..5f5a7fd853 100644 --- a/internal/entity/models/voyage_test.go +++ b/internal/entity/models/voyage_test.go @@ -66,6 +66,7 @@ func TestVoyageNewModelWithCustomDefaultTransport(t *testing.T) { } func TestVoyageEmbedHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "voyage-3.5" { @@ -103,6 +104,7 @@ func TestVoyageEmbedHappyPath(t *testing.T) { // "dimensions" returns "Argument 'dimensions' is not supported by our // API"), so this name matters and must not regress. func TestVoyageEmbedPropagatesOutputDimension(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if got, ok := body["output_dimension"].(float64); !ok || got != 256 { @@ -133,6 +135,7 @@ func TestVoyageEmbedPropagatesOutputDimension(t *testing.T) { // would default the vector length, but only if we don't send the key // at all (sending output_dimension: 0 is a 400). func TestVoyageEmbedOmitsOutputDimensionWhenUnset(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if _, present := body["output_dimension"]; present { @@ -154,6 +157,7 @@ func TestVoyageEmbedOmitsOutputDimensionWhenUnset(t *testing.T) { } func TestVoyageEmbedReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -181,6 +185,7 @@ func TestVoyageEmbedReordersByIndex(t *testing.T) { } func TestVoyageEmbedEmptyInputShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("Embed([]) made an unexpected HTTP call") @@ -197,6 +202,7 @@ func TestVoyageEmbedEmptyInputShortCircuits(t *testing.T) { } func TestVoyageEmbedRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() v := newVoyageForTest("http://unused") model := "voyage-3.5" @@ -207,6 +213,7 @@ func TestVoyageEmbedRequiresAPIKey(t *testing.T) { } func TestVoyageEmbedRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() v := newVoyageForTest("http://unused") apiKey := "test-key" @@ -217,6 +224,7 @@ func TestVoyageEmbedRequiresModelName(t *testing.T) { } func TestVoyageEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -238,6 +246,7 @@ func TestVoyageEmbedRejectsDuplicateIndex(t *testing.T) { } func TestVoyageEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -258,6 +267,7 @@ func TestVoyageEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestVoyageEmbedRejectsMissingSlot(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -278,6 +288,7 @@ func TestVoyageEmbedRejectsMissingSlot(t *testing.T) { } func TestVoyageRerankHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { // Voyage's request key is top_k (not top_n). @@ -319,6 +330,7 @@ func TestVoyageRerankHappyPath(t *testing.T) { } func TestVoyageRerankTopKDefaultsToLenDocuments(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["top_k"] != float64(4) { @@ -339,6 +351,7 @@ func TestVoyageRerankTopKDefaultsToLenDocuments(t *testing.T) { } func TestVoyageRerankEmptyDocuments(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() v := newVoyageForTest("http://unused") apiKey := "test-key" @@ -354,6 +367,7 @@ func TestVoyageRerankEmptyDocuments(t *testing.T) { } func TestVoyageRerankRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -375,6 +389,7 @@ func TestVoyageRerankRejectsOutOfRangeIndex(t *testing.T) { } func TestVoyageRerankRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() // A duplicate index would silently overwrite an earlier slot, which // is the same failure mode Embed already guards against. Make sure @@ -404,6 +419,7 @@ func TestVoyageRerankRejectsDuplicateIndex(t *testing.T) { // (e.g. `.../v1//embeddings`). Rerank already trims, so Embed must // trim too; CodeRabbit flagged the inconsistency. func TestVoyageEmbedTrimsTrailingSlashInBaseURL(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var sawPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/entity/models/xai.go b/internal/entity/models/xai.go index d0057b3476..b509ce3750 100644 --- a/internal/entity/models/xai.go +++ b/internal/entity/models/xai.go @@ -49,7 +49,7 @@ func NewXAIModel(baseURL map[string]string, urlSuffix URLSuffix) *XAIModel { baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/xai_test.go b/internal/entity/models/xai_test.go index 25267917a2..6a3e46bff7 100644 --- a/internal/entity/models/xai_test.go +++ b/internal/entity/models/xai_test.go @@ -43,6 +43,7 @@ func TestXAIConfigDeclaresModelsSuffix(t *testing.T) { } func TestXAIListModelsHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -80,6 +81,7 @@ func TestXAIListModelsHappyPath(t *testing.T) { } func TestXAIListModelsRequiresAPIKey(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() _, err := newXAIForTest("http://unused").ListModels(ctx, &APIConfig{}) if err == nil || !strings.Contains(err.Error(), "api key is required") { @@ -88,6 +90,7 @@ func TestXAIListModelsRequiresAPIKey(t *testing.T) { } func TestXAIListModelsRejectsProviderError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad key", http.StatusUnauthorized) @@ -102,6 +105,7 @@ func TestXAIListModelsRejectsProviderError(t *testing.T) { } func TestXAICheckConnectionDelegatesToListModels(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/models" { @@ -118,6 +122,7 @@ func TestXAICheckConnectionDelegatesToListModels(t *testing.T) { } func TestXAIListModelsRequiresModelsSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("ListModels should reject a missing models suffix before sending a request") diff --git a/internal/entity/models/xiaomi.go b/internal/entity/models/xiaomi.go index 82faf3cad0..23440b2051 100644 --- a/internal/entity/models/xiaomi.go +++ b/internal/entity/models/xiaomi.go @@ -40,7 +40,7 @@ func NewXiaomiModel(baseURL map[string]string, urlSuffix URLSuffix) *XiaomiModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/xiaomi_test.go b/internal/entity/models/xiaomi_test.go index 31637cd684..b46f8b3972 100644 --- a/internal/entity/models/xiaomi_test.go +++ b/internal/entity/models/xiaomi_test.go @@ -80,6 +80,7 @@ func TestXiaomiNewModelWithCustomDefaultTransport(t *testing.T) { } func TestXiaomiChatHappyPath(t *testing.T) { + withSSRFBypass(t) srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "mimo-v2.5-pro" { t.Errorf("model=%v", body["model"]) @@ -130,6 +131,7 @@ func TestXiaomiChatHappyPath(t *testing.T) { } func TestXiaomiUsesEmptyRegionBaseURLOverride(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -157,6 +159,7 @@ func TestXiaomiUsesEmptyRegionBaseURLOverride(t *testing.T) { } func TestXiaomiAPIConfigBaseURLOverridesRegionMap(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/override/chat", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -185,6 +188,7 @@ func TestXiaomiAPIConfigBaseURLOverridesRegionMap(t *testing.T) { } func TestXiaomiChatExtractsReasoningContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -209,6 +213,7 @@ func TestXiaomiChatExtractsReasoningContent(t *testing.T) { } func TestXiaomiChatRequiresInputs(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" m := newXiaomiForTest("http://unused") @@ -224,6 +229,7 @@ func TestXiaomiChatRequiresInputs(t *testing.T) { } func TestXiaomiChatRejectsHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) @@ -239,6 +245,7 @@ func TestXiaomiChatRejectsHTTPError(t *testing.T) { } func TestXiaomiStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["stream"] != true { @@ -292,6 +299,7 @@ func TestXiaomiStreamHappyPath(t *testing.T) { } func TestXiaomiStreamHandlesCRLFFrames(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -327,6 +335,7 @@ func TestXiaomiStreamHandlesCRLFFrames(t *testing.T) { } func TestXiaomiStreamRejectsMalformedFrame(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") @@ -343,6 +352,7 @@ func TestXiaomiStreamRejectsMalformedFrame(t *testing.T) { } func TestXiaomiUnsupportedMethods(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() m := newXiaomiForTest("http://unused") model := "mimo-v2.5-pro" diff --git a/internal/entity/models/xinference.go b/internal/entity/models/xinference.go index 8ca806cea4..5647575a8c 100644 --- a/internal/entity/models/xinference.go +++ b/internal/entity/models/xinference.go @@ -63,7 +63,7 @@ func NewXinferenceModel(baseURL map[string]string, urlSuffix URLSuffix) *Xinfere BaseURL: baseURL, URLSuffix: urlSuffix, AllowEmptyAPIKey: true, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(true), }, } } diff --git a/internal/entity/models/xinference_test.go b/internal/entity/models/xinference_test.go index 872c9564bc..3601c5b0ca 100644 --- a/internal/entity/models/xinference_test.go +++ b/internal/entity/models/xinference_test.go @@ -66,6 +66,7 @@ func TestXinferenceFactoryRoute(t *testing.T) { } func TestXinferenceChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() var seen map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -113,6 +114,7 @@ func TestXinferenceChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) } func TestXinferenceChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer sk-test" { @@ -133,6 +135,7 @@ func TestXinferenceChatSendsAuthHeaderWhenKeyProvided(t *testing.T) { } func TestXinferenceChatExtractsReasoningFields(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"choices":[{"message":{ @@ -155,6 +158,7 @@ func TestXinferenceChatExtractsReasoningFields(t *testing.T) { } func TestXinferenceStreamHappyPath(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { @@ -210,6 +214,7 @@ func TestXinferenceStreamHappyPath(t *testing.T) { } func TestXinferenceStreamRejectsFalseStreamConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") stream := false @@ -225,6 +230,7 @@ func TestXinferenceStreamRejectsFalseStreamConfig(t *testing.T) { } func TestXinferenceStreamCancelsOnIdle(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() withXinferenceIdleTimeout(t, 200*time.Millisecond) @@ -256,6 +262,7 @@ func TestXinferenceStreamCancelsOnIdle(t *testing.T) { } func TestXinferenceListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { @@ -313,6 +320,7 @@ func newXinferenceEmbedServer(t *testing.T, handler func(t *testing.T, body map[ } func TestXinferenceEmbedHappyPathAndOmitsEmptyAuth(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "bge-m3" { @@ -346,6 +354,7 @@ func TestXinferenceEmbedHappyPathAndOmitsEmptyAuth(t *testing.T) { } func TestXinferenceEmbedSendsAuthWhenKeyConfigured(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() gotAuth := "" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -366,6 +375,7 @@ func TestXinferenceEmbedSendsAuthWhenKeyConfigured(t *testing.T) { } func TestXinferenceEmbedNormalizesBaseURLWithV1Suffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1]}]}`) @@ -383,6 +393,7 @@ func TestXinferenceEmbedNormalizesBaseURLWithV1Suffix(t *testing.T) { } func TestXinferenceEmbedForwardsDimension(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["dimensions"] != float64(384) { @@ -400,6 +411,7 @@ func TestXinferenceEmbedForwardsDimension(t *testing.T) { } func TestXinferenceEmbedRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1]},{"index":0,"embedding":[0.2]}]}`) @@ -415,6 +427,7 @@ func TestXinferenceEmbedRejectsDuplicateIndex(t *testing.T) { } func TestXinferenceEmbedRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _, _ = io.WriteString(w, `{"data":[{"index":5,"embedding":[0.1]}]}`) @@ -430,6 +443,7 @@ func TestXinferenceEmbedRejectsOutOfRangeIndex(t *testing.T) { } func TestXinferenceEmbedRejectsMissingIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { // Two inputs requested but only one returned — index 1 is missing. @@ -446,6 +460,7 @@ func TestXinferenceEmbedRejectsMissingIndex(t *testing.T) { } func TestXinferenceEmbedEmptyTextsShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") model := "bge-m3" @@ -459,6 +474,7 @@ func TestXinferenceEmbedEmptyTextsShortCircuits(t *testing.T) { } func TestXinferenceEmbedRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") _, err := x.Embed(ctx, nil, []string{"x"}, &APIConfig{}, nil, nil) @@ -468,6 +484,7 @@ func TestXinferenceEmbedRequiresModelName(t *testing.T) { } func TestXinferenceEmbedSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -484,6 +501,7 @@ func TestXinferenceEmbedSurfacesHTTPError(t *testing.T) { } func TestXinferenceEmbedRejectsMissingEmbeddingSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := NewXinferenceModel( map[string]string{"default": "http://unused"}, @@ -497,6 +515,7 @@ func TestXinferenceEmbedRejectsMissingEmbeddingSuffix(t *testing.T) { } func TestXinferenceMissingBaseURLFailsClearly(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := NewXinferenceModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"}) _, err := x.ChatWithMessages(ctx, "qwen2.5-instruct", @@ -508,6 +527,7 @@ func TestXinferenceMissingBaseURLFailsClearly(t *testing.T) { } func TestXinferenceUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") model := "qwen2.5-instruct" @@ -566,6 +586,7 @@ func newXinferenceRerankServer(t *testing.T, expectedAuth string, handler func(t } func TestXinferenceRerankHappyPathReordersByIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "bge-reranker-v2-m3" { @@ -608,6 +629,7 @@ func TestXinferenceRerankHappyPathReordersByIndex(t *testing.T) { } func TestXinferenceRerankNormalizesV1BaseURL(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"results": []map[string]interface{}{}}) @@ -627,6 +649,7 @@ func TestXinferenceRerankNormalizesV1BaseURL(t *testing.T) { } func TestXinferenceRerankRespectsTopNConfig(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if got := body["top_n"].(float64); got != 2 { @@ -645,6 +668,7 @@ func TestXinferenceRerankRespectsTopNConfig(t *testing.T) { } func TestXinferenceRerankEmptyDocumentsShortCircuits(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") model := "bge-reranker-v2-m3" @@ -658,6 +682,7 @@ func TestXinferenceRerankEmptyDocumentsShortCircuits(t *testing.T) { } func TestXinferenceRerankRequiresModelName(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := newXinferenceForTest("http://unused") _, err := x.Rerank(ctx, nil, "q", []string{"a"}, &APIConfig{}, nil, nil) @@ -667,6 +692,7 @@ func TestXinferenceRerankRequiresModelName(t *testing.T) { } func TestXinferenceRerankRejectsOutOfRangeIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -684,6 +710,7 @@ func TestXinferenceRerankRejectsOutOfRangeIndex(t *testing.T) { } func TestXinferenceRerankRejectsDuplicateIndex(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -704,6 +731,7 @@ func TestXinferenceRerankRejectsDuplicateIndex(t *testing.T) { } func TestXinferenceRerankSurfacesHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -720,6 +748,7 @@ func TestXinferenceRerankSurfacesHTTPError(t *testing.T) { } func TestXinferenceRerankRejectsMissingRerankSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() x := NewXinferenceModel( map[string]string{"default": "http://unused"}, diff --git a/internal/entity/models/xunfei.go b/internal/entity/models/xunfei.go index 79caafcb28..b27ac957d4 100644 --- a/internal/entity/models/xunfei.go +++ b/internal/entity/models/xunfei.go @@ -35,7 +35,7 @@ func NewXunFeiModel(baseURL map[string]string, urlSuffix URLSuffix) *XunFeiModel baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/xunfei_test.go b/internal/entity/models/xunfei_test.go index 781d719f13..b09bfa6a98 100644 --- a/internal/entity/models/xunfei_test.go +++ b/internal/entity/models/xunfei_test.go @@ -3,6 +3,7 @@ package models import "testing" func TestXunFeiUnsupportedMethodsReturnNoSuchMethod(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() driver := NewXunFeiModel(map[string]string{"default": "http://unused"}, URLSuffix{}). NewInstance(map[string]string{"default": "http://unused"}) diff --git a/internal/entity/models/zhipu-ai.go b/internal/entity/models/zhipu-ai.go index aa3de79fca..6b0d7b72be 100644 --- a/internal/entity/models/zhipu-ai.go +++ b/internal/entity/models/zhipu-ai.go @@ -42,7 +42,7 @@ func NewZhipuAIModel(baseURL map[string]string, urlSuffix URLSuffix) *ZhipuAIMod baseModel: BaseModel{ BaseURL: baseURL, URLSuffix: urlSuffix, - httpClient: NewDriverHTTPClient(), + httpClient: NewDriverHTTPClient(false), }, } } diff --git a/internal/entity/models/zhipu-ai_test.go b/internal/entity/models/zhipu-ai_test.go index dad8fa7f46..9dc7f57a58 100644 --- a/internal/entity/models/zhipu-ai_test.go +++ b/internal/entity/models/zhipu-ai_test.go @@ -9,6 +9,7 @@ import ( ) func TestZhipuAIOCRFileSendsLayoutParsingRequest(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-ocr" @@ -63,6 +64,7 @@ func TestZhipuAIOCRFileSendsLayoutParsingRequest(t *testing.T) { } func TestZhipuAIOCRFileEncodesContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-ocr" @@ -89,6 +91,7 @@ func TestZhipuAIOCRFileEncodesContent(t *testing.T) { } func TestZhipuAIOCRFileDetectsPDFContent(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-ocr" @@ -115,6 +118,7 @@ func TestZhipuAIOCRFileDetectsPDFContent(t *testing.T) { } func TestZhipuAIOCRFileValidation(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-ocr" diff --git a/internal/entity/models/zhipu_ai_test.go b/internal/entity/models/zhipu_ai_test.go index b0ae7e685a..6895acd923 100644 --- a/internal/entity/models/zhipu_ai_test.go +++ b/internal/entity/models/zhipu_ai_test.go @@ -33,6 +33,7 @@ func writeZhipuAITestAudio(t *testing.T) string { } func TestZhipuAITranscribeAudio(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -117,6 +118,7 @@ func TestZhipuAITranscribeAudio(t *testing.T) { } func TestZhipuAITranscribeAudioValidation(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-asr-2512" @@ -145,6 +147,7 @@ func TestZhipuAITranscribeAudioValidation(t *testing.T) { } func TestZhipuAITranscribeAudioRequiresASRSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-asr-2512" @@ -163,6 +166,7 @@ func TestZhipuAITranscribeAudioRequiresASRSuffix(t *testing.T) { } func TestZhipuAITranscribeAudioHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) @@ -180,6 +184,7 @@ func TestZhipuAITranscribeAudioHTTPError(t *testing.T) { } func TestZhipuAIAudioSpeech(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -250,6 +255,7 @@ func TestZhipuAIAudioSpeech(t *testing.T) { } func TestZhipuAIAudioSpeechWithSender(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} @@ -295,6 +301,7 @@ func TestZhipuAIAudioSpeechWithSender(t *testing.T) { } func TestZhipuAIAudioSpeechValidation(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-tts" @@ -323,6 +330,7 @@ func TestZhipuAIAudioSpeechValidation(t *testing.T) { } func TestZhipuAIAudioSpeechRequiresTTSSuffix(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() apiKey := "test-key" modelName := "glm-tts" @@ -341,6 +349,7 @@ func TestZhipuAIAudioSpeechRequiresTTSSuffix(t *testing.T) { } func TestZhipuAIAudioSpeechHTTPError(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) @@ -358,6 +367,7 @@ func TestZhipuAIAudioSpeechHTTPError(t *testing.T) { } func TestZhipuAIChatStreamlyWithSenderCollectsToolCalls(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/ingestion/component/parser_dispatch_test.go b/internal/ingestion/component/parser_dispatch_test.go index b0e866c167..3a355682bd 100644 --- a/internal/ingestion/component/parser_dispatch_test.go +++ b/internal/ingestion/component/parser_dispatch_test.go @@ -544,6 +544,7 @@ func TestDispatch_PDFVisionJSON_PreservesEmptyPages(t *testing.T) { } func TestDispatch_PDFMinerUMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == "/file_parse" { buf := new(bytes.Buffer) @@ -644,6 +645,7 @@ func (d *mineruTestDriver) ShowTask(ctx context.Context, taskID string, apiConfi } func TestDispatch_PDFPaddleOCRMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/layout-parsing" { http.NotFound(w, r) @@ -684,6 +686,7 @@ func TestDispatch_PDFPaddleOCRMarkdown_UsesConfiguredBackend(t *testing.T) { } func TestDispatch_PDFDoclingMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) var requestCount int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ @@ -736,6 +739,7 @@ func TestDispatch_PDFDoclingMarkdown_UsesConfiguredBackend(t *testing.T) { } func TestDispatch_PDFOpenDataLoaderMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/file_parse" { http.NotFound(w, r) @@ -768,6 +772,7 @@ func TestDispatch_PDFOpenDataLoaderMarkdown_UsesConfiguredBackend(t *testing.T) } func TestDispatch_PDFSoMarkMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/parse/async": @@ -802,6 +807,7 @@ func TestDispatch_PDFSoMarkMarkdown_UsesConfiguredBackend(t *testing.T) { } func TestDispatch_PDFTCADPMarkdown_UsesConfiguredBackend(t *testing.T) { + withSSRFBypass(t) zipPayload := tcadpZipFixtureForComponent(t) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ingestion/component/ssrf_test_helper_test.go b/internal/ingestion/component/ssrf_test_helper_test.go new file mode 100644 index 0000000000..9bb883efe8 --- /dev/null +++ b/internal/ingestion/component/ssrf_test_helper_test.go @@ -0,0 +1,19 @@ +package component + +import ( + "testing" + + "ragflow/internal/utility" +) + +// withSSRFBypass enables the test-only SSRF bypass for the duration of a test +// and restores the previous value in t.Cleanup. Component tests exercise +// parsing/dispatch logic against httptest servers bound to 127.0.0.1, which +// the strict SSRF guard rejects. SSRF enforcement has its own dedicated +// coverage in internal/utility/ssrf_test.go. +func withSSRFBypass(t *testing.T) { + t.Helper() + prev := utility.AllowAnyHostForTest + utility.AllowAnyHostForTest = true + t.Cleanup(func() { utility.AllowAnyHostForTest = prev }) +} diff --git a/internal/parser/parser/markdown_parser_test.go b/internal/parser/parser/markdown_parser_test.go index 64afa19b56..049b9b12ad 100644 --- a/internal/parser/parser/markdown_parser_test.go +++ b/internal/parser/parser/markdown_parser_test.go @@ -203,6 +203,7 @@ func TestResolveMarkdownImage_NoImage(t *testing.T) { } func TestResolveMarkdownImage_HTTPImage(t *testing.T) { + withSSRFBypass(t) // httptest servers bind loopback, which the SSRF guard rejects by // default. Allow loopback for this test so the HTTP fetch path is // exercised (production keeps ssrfAllowLoopback == false). @@ -236,6 +237,7 @@ func TestFetchImageAsBase64_RejectsCredentials(t *testing.T) { } func TestFetchImageAsBase64_InvalidURL(t *testing.T) { + withSSRFBypass(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) diff --git a/internal/parser/parser/pdf_parser_docling.go b/internal/parser/parser/pdf_parser_docling.go index 9d75aee3c2..48a2792229 100644 --- a/internal/parser/parser/pdf_parser_docling.go +++ b/internal/parser/parser/pdf_parser_docling.go @@ -88,7 +88,7 @@ func parsePDFWithDocling(ctx context.Context, filename string, data []byte, pars var lastErr error for _, candidate := range payloads { url := baseURL + candidate.endpoint - resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), url, auth, candidate.body()) + resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), url, auth, candidate.body()) if err != nil { lastErr = fmt.Errorf("%s: %w", candidate.endpoint, err) continue diff --git a/internal/parser/parser/pdf_parser_docling_test.go b/internal/parser/parser/pdf_parser_docling_test.go index 43ed17ef53..a6fa3bdd1c 100644 --- a/internal/parser/parser/pdf_parser_docling_test.go +++ b/internal/parser/parser/pdf_parser_docling_test.go @@ -11,6 +11,7 @@ import ( ) func TestPDFParser_ParseWithResult_DoclingChunkedMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) var requestCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -84,6 +85,7 @@ func TestPDFParser_ParseWithResult_DoclingChunkedMarkdownIntegration(t *testing. } func TestPDFParser_ParseWithResult_DoclingFallbackToStandardJSONIntegration(t *testing.T) { + withSSRFBypass(t) var requestCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/parser/parser/pdf_parser_mineru_test.go b/internal/parser/parser/pdf_parser_mineru_test.go index 85eab7fe33..b16af3b514 100644 --- a/internal/parser/parser/pdf_parser_mineru_test.go +++ b/internal/parser/parser/pdf_parser_mineru_test.go @@ -11,6 +11,7 @@ import ( ) func TestPDFParser_ParseWithResult_MinerUMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) var submitCalled atomic.Bool var resultCalled atomic.Bool @@ -100,6 +101,7 @@ func TestPDFParser_ParseWithResult_MinerUMarkdownIntegration(t *testing.T) { } func TestPDFParser_ParseWithResult_MinerUJSONIntegration(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/file_parse": diff --git a/internal/parser/parser/pdf_parser_opendataloader.go b/internal/parser/parser/pdf_parser_opendataloader.go index 089327428a..5b08566a6c 100644 --- a/internal/parser/parser/pdf_parser_opendataloader.go +++ b/internal/parser/parser/pdf_parser_opendataloader.go @@ -41,7 +41,7 @@ func parsePDFWithOpenDataLoader(ctx context.Context, filename string, data []byt if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } - resp, err := models.NewDriverHTTPClient().Do(req) + resp, err := models.NewDriverHTTPClient(false).Do(req) if err != nil { return ParseResult{Err: fmt.Errorf("parser: OpenDataLoader submit: %w", err)} } diff --git a/internal/parser/parser/pdf_parser_opendataloader_test.go b/internal/parser/parser/pdf_parser_opendataloader_test.go index 89e2e93d03..9d39683232 100644 --- a/internal/parser/parser/pdf_parser_opendataloader_test.go +++ b/internal/parser/parser/pdf_parser_opendataloader_test.go @@ -10,6 +10,7 @@ import ( ) func TestPDFParser_ParseWithResult_OpenDataLoaderJSONIntegration(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/file_parse" { http.NotFound(w, r) @@ -84,6 +85,7 @@ func TestPDFParser_ParseWithResult_OpenDataLoaderJSONIntegration(t *testing.T) { } func TestPDFParser_ParseWithResult_OpenDataLoaderMarkdownFallback(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/file_parse" { http.NotFound(w, r) diff --git a/internal/parser/parser/pdf_parser_paddleocr_test.go b/internal/parser/parser/pdf_parser_paddleocr_test.go index 3b7f8fae0e..bb42b4ac3d 100644 --- a/internal/parser/parser/pdf_parser_paddleocr_test.go +++ b/internal/parser/parser/pdf_parser_paddleocr_test.go @@ -11,6 +11,7 @@ import ( ) func TestPDFParser_ParseWithResult_PaddleOCRMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) var called atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -82,6 +83,7 @@ func TestPDFParser_ParseWithResult_PaddleOCRMarkdownIntegration(t *testing.T) { } func TestPDFParser_ParseWithResult_PaddleOCRJSONIntegration(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/layout-parsing" { http.NotFound(w, r) diff --git a/internal/parser/parser/pdf_parser_somark.go b/internal/parser/parser/pdf_parser_somark.go index a8ed7db7b7..b58395f548 100644 --- a/internal/parser/parser/pdf_parser_somark.go +++ b/internal/parser/parser/pdf_parser_somark.go @@ -84,7 +84,7 @@ func soMarkSubmit(baseURL, filename string, data []byte, parser *PDFParser, apiK return "", fmt.Errorf("parser: SoMark request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := models.NewDriverHTTPClient().Do(req) + resp, err := models.NewDriverHTTPClient(false).Do(req) if err != nil { return "", fmt.Errorf("parser: SoMark submit: %w", err) } @@ -122,7 +122,7 @@ func soMarkPoll(baseURL, taskID, apiKey string) (map[string]any, error) { return nil, fmt.Errorf("parser: SoMark poll request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := models.NewDriverHTTPClient().Do(req) + resp, err := models.NewDriverHTTPClient(false).Do(req) if err != nil { return nil, fmt.Errorf("parser: SoMark poll: %w", err) } diff --git a/internal/parser/parser/pdf_parser_somark_test.go b/internal/parser/parser/pdf_parser_somark_test.go index a86641c499..d94d326720 100644 --- a/internal/parser/parser/pdf_parser_somark_test.go +++ b/internal/parser/parser/pdf_parser_somark_test.go @@ -10,6 +10,7 @@ import ( ) func TestPDFParser_ParseWithResult_SoMarkJSONIntegration(t *testing.T) { + withSSRFBypass(t) var submitSeen bool var pollSeen bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -78,6 +79,7 @@ func TestPDFParser_ParseWithResult_SoMarkJSONIntegration(t *testing.T) { } func TestPDFParser_ParseWithResult_SoMarkMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/parse/async": @@ -123,6 +125,7 @@ func TestSoMarkBlockToItem_DropsHeaderByDefault(t *testing.T) { } func TestSoMarkSubmitMultipartShape(t *testing.T) { + withSSRFBypass(t) var form multipart.Form server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseMultipartForm(1 << 20) diff --git a/internal/parser/parser/pdf_parser_tcadp.go b/internal/parser/parser/pdf_parser_tcadp.go index 126d9cd99e..1284e2699a 100644 --- a/internal/parser/parser/pdf_parser_tcadp.go +++ b/internal/parser/parser/pdf_parser_tcadp.go @@ -40,7 +40,7 @@ func parsePDFWithTCADP(filename string, data []byte, parser *PDFParser) ParseRes "MarkdownImageResponseType": parser.TCADPMarkdownImageResponseType, }, } - resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody) + resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)} } @@ -68,7 +68,7 @@ func parsePDFWithTCADP(filename string, data []byte, parser *PDFParser) ParseRes if auth := bearer(apiKey); auth != "" { downloadReq.Header.Set("Authorization", auth) } - downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq) + downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)} } diff --git a/internal/parser/parser/pdf_parser_tcadp_test.go b/internal/parser/parser/pdf_parser_tcadp_test.go index fae95fbe97..bf517546a7 100644 --- a/internal/parser/parser/pdf_parser_tcadp_test.go +++ b/internal/parser/parser/pdf_parser_tcadp_test.go @@ -10,6 +10,7 @@ import ( ) func TestPDFParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { + withSSRFBypass(t) zipPayload := tcadpZipFixture(t) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -47,6 +48,7 @@ func TestPDFParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { } func TestPDFParser_ParseWithResult_TCADPMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) zipPayload := tcadpZipFixture(t) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/parser/parser/ppt_parser_cgo_test.go b/internal/parser/parser/ppt_parser_cgo_test.go index 81591d0142..9cab23d9dd 100644 --- a/internal/parser/parser/ppt_parser_cgo_test.go +++ b/internal/parser/parser/ppt_parser_cgo_test.go @@ -135,6 +135,7 @@ type tcadpPresentationParser interface { } func testPresentationTCADPIntegration(t *testing.T, p tcadpPresentationParser, wantFileType, filename string) { + withSSRFBypass(t) t.Helper() zipPayload := tcadpZipFixture(t) var gotFileType string @@ -189,6 +190,7 @@ func testPresentationTCADPIntegration(t *testing.T, p tcadpPresentationParser, w // from the TCADP download endpoint is surfaced as an explicit error rather // than parsed as a (malformed) ZIP artifact. func TestPPTXParser_TCADPDownloadHTTPError(t *testing.T) { + withSSRFBypass(t) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/internal/parser/parser/pptx_tcadp.go b/internal/parser/parser/pptx_tcadp.go index 47620ffa72..ad97d0a4c8 100644 --- a/internal/parser/parser/pptx_tcadp.go +++ b/internal/parser/parser/pptx_tcadp.go @@ -45,7 +45,7 @@ func parsePresentationWithTCADP(ctx context.Context, filename string, data []byt "MarkdownImageResponseType": markdownImageResponseType, }, } - resp, err := models.PostJSONRequest(ctx, models.NewDriverHTTPClient(), + resp, err := models.PostJSONRequest(ctx, models.NewDriverHTTPClient(false), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)} @@ -75,7 +75,7 @@ func parsePresentationWithTCADP(ctx context.Context, filename string, data []byt if auth := bearer(apiKey); auth != "" { downloadReq.Header.Set("Authorization", auth) } - downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq) + downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)} } diff --git a/internal/parser/parser/ssrf_test_helper_test.go b/internal/parser/parser/ssrf_test_helper_test.go new file mode 100644 index 0000000000..a0b76f4604 --- /dev/null +++ b/internal/parser/parser/ssrf_test_helper_test.go @@ -0,0 +1,19 @@ +package parser + +import ( + "testing" + + "ragflow/internal/utility" +) + +// withSSRFBypass enables the test-only SSRF bypass for the duration of a test +// and restores the previous value in t.Cleanup. Parser tests exercise parsing +// logic against httptest servers bound to 127.0.0.1, which the strict SSRF +// guard rejects. SSRF enforcement has its own dedicated coverage in +// internal/utility/ssrf_test.go. +func withSSRFBypass(t *testing.T) { + t.Helper() + prev := utility.AllowAnyHostForTest + utility.AllowAnyHostForTest = true + t.Cleanup(func() { utility.AllowAnyHostForTest = prev }) +} diff --git a/internal/parser/parser/xls_tcadp.go b/internal/parser/parser/xls_tcadp.go index e0c31e0972..9468450161 100644 --- a/internal/parser/parser/xls_tcadp.go +++ b/internal/parser/parser/xls_tcadp.go @@ -38,7 +38,7 @@ func parseSpreadsheetWithTCADP(filename string, data []byte, fileType string, tc "MarkdownImageResponseType": markdownImageResponseType, }, } - resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody) + resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)} } @@ -66,7 +66,7 @@ func parseSpreadsheetWithTCADP(filename string, data []byte, fileType string, tc if auth := bearer(apiKey); auth != "" { downloadReq.Header.Set("Authorization", auth) } - downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq) + downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq) if err != nil { return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)} } diff --git a/internal/parser/parser/xls_tcadp_test.go b/internal/parser/parser/xls_tcadp_test.go index 111ca4bb00..96b5a8bc73 100644 --- a/internal/parser/parser/xls_tcadp_test.go +++ b/internal/parser/parser/xls_tcadp_test.go @@ -7,6 +7,7 @@ import ( ) func TestXLSXParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() zipPayload := tcadpZipFixture(t) var server *httptest.Server @@ -45,6 +46,7 @@ func TestXLSXParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { } func TestXLSParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() zipPayload := tcadpZipFixture(t) var server *httptest.Server @@ -80,6 +82,7 @@ func TestXLSParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { } func TestCSVParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() zipPayload := tcadpZipFixture(t) var server *httptest.Server @@ -112,6 +115,7 @@ func TestCSVParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) { } func TestXLSXParser_ParseWithResult_TCADPMarkdownIntegration(t *testing.T) { + withSSRFBypass(t) ctx := t.Context() zipPayload := tcadpZipFixture(t) var server *httptest.Server diff --git a/internal/utility/ssrf.go b/internal/utility/ssrf.go index c271dda88c..60cc256444 100644 --- a/internal/utility/ssrf.go +++ b/internal/utility/ssrf.go @@ -172,6 +172,31 @@ func allZero(b []byte) bool { return true } +// AssertURLSchemeSafe is a lenient SSRF guard for drivers that may legitimately +// target private networks or loopback addresses (e.g. self-hosted Ollama, vLLM, +// Xinference). It only rejects dangerous schemes and empty hosts; it does not +// resolve DNS and does not require public routability. Use this ONLY for +// local-inference model drivers — cloud-hosted drivers must use AssertURLSafe. +var AssertURLSchemeSafe = func(rawURL string) error { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("invalid url") + } + + scheme := strings.ToLower(parsed.Scheme) + if !slices.Contains(AllowedURLSchemes, scheme) { + sorted := append([]string(nil), AllowedURLSchemes...) + sort.Strings(sorted) + return fmt.Errorf("disallowed URL scheme: '%s'. Only %v are allowed", scheme, sorted) + } + + if parsed.Hostname() == "" { + return fmt.Errorf("URL is missing a host") + } + + return nil +} + // PinnedHTTPClient returns an HTTP client whose Transport rewrites every // outbound dial for hostname:port to resolvedIP:port, closing the TOCTOU // window between AssertURLSafe and the actual TCP connection. Pins are