@@ -4,11 +4,12 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
8+ apiErrors "evo-ai-core-service/internal/httpclient/errors"
79 errorsPostgres "evo-ai-core-service/internal/infra/postgres"
810 "evo-ai-core-service/internal/utils/stringutils"
911 model "evo-ai-core-service/pkg/custom_tool/model"
1012 repository "evo-ai-core-service/pkg/custom_tool/repository"
11- "errors"
1213 "fmt"
1314 "io"
1415 "net"
@@ -165,9 +166,59 @@ var testHTTPClient = &http.Client{
165166 },
166167}
167168
169+ // toQueryValue renders a query-param value as a string. Scalars go verbatim;
170+ // objects/arrays are JSON-encoded so we never emit Go's map[k:v] syntax.
171+ func toQueryValue (v interface {}) string {
172+ switch t := v .(type ) {
173+ case nil :
174+ return ""
175+ case string :
176+ return t
177+ case map [string ]interface {}, []interface {}:
178+ if buf , err := json .Marshal (t ); err == nil {
179+ return string (buf )
180+ }
181+ return fmt .Sprintf ("%v" , t )
182+ default :
183+ return fmt .Sprintf ("%v" , t )
184+ }
185+ }
186+
187+ // resolveToolURL applies the tool's path placeholders and query params to the raw
188+ // endpoint, yielding the URL the tool actually calls. Without this the test ran a
189+ // bare endpoint — `/users/{user_id}` was requested literally and query params were
190+ // dropped, so the result did not reflect the configured tool.
191+ //
192+ // Safe by construction: the result is fed to runToolTest, which runs
193+ // validateEndpoint on the FINAL url — so a placeholder that expands into an
194+ // internal host is still caught by the SSRF gate.
195+ func resolveToolURL (endpoint string , pathParams map [string ]string , queryParams map [string ]interface {}) string {
196+ resolved := endpoint
197+ for k , v := range pathParams {
198+ resolved = strings .ReplaceAll (resolved , "{" + k + "}" , url .PathEscape (v ))
199+ }
200+
201+ if len (queryParams ) == 0 {
202+ return resolved
203+ }
204+
205+ u , err := url .Parse (strings .TrimSpace (resolved ))
206+ if err != nil {
207+ // Let validateEndpoint produce the user-facing parse error.
208+ return resolved
209+ }
210+ q := u .Query ()
211+ for k , v := range queryParams {
212+ q .Set (k , toQueryValue (v ))
213+ }
214+ u .RawQuery = q .Encode ()
215+ return u .String ()
216+ }
217+
168218// runToolTest issues the HTTP request described by the tool and returns a
169219// TestResult reflecting what actually happened: real status code, response
170220// time, headers, body. Success = HTTP 2xx (not body parseability).
221+ // `endpoint` must already be resolved (see resolveToolURL).
171222func runToolTest (
172223 ctx context.Context ,
173224 method , endpoint string ,
@@ -279,6 +330,9 @@ type CustomToolService interface {
279330 Delete (ctx context.Context , id uuid.UUID ) (bool , error )
280331 ConvertToHTTPTool (tool model.CustomToolResponse ) map [string ]interface {}
281332 Test (ctx context.Context , id uuid.UUID ) (* model.CustomToolTestResponse , error )
333+ // EVO-1738: stateless test of an UNSAVED tool payload (test-before-save in the
334+ // wizard). Same SSRF-hardened runToolTest as Test, without requiring a saved tool.
335+ TestPayload (ctx context.Context , req model.CustomToolTestPayloadRequest ) (* model.TestResult , error )
282336}
283337
284338type customToolService struct {
@@ -420,21 +474,52 @@ func (s *customToolService) Test(ctx context.Context, id uuid.UUID) (*model.Cust
420474 }
421475
422476 response := customTool .ToResponse ()
423- method := strings .ToUpper (customTool .Method )
424477
425- switch method {
426- case http .MethodGet , http .MethodPost , http .MethodPut , http .MethodDelete , http .MethodPatch , http .MethodHead , http .MethodOptions :
427- // supported
428- default :
429- return nil , fmt .Errorf ("unsupported method: %s" , customTool .Method )
478+ method , err := normalizeToolMethod (customTool .Method )
479+ if err != nil {
480+ return nil , err
430481 }
431482
432483 headers := stringutils .JSONToStringMap (customTool .Headers )
433484 bodyParams := stringutils .JSONToInterfaceMap (customTool .BodyParams )
434- testResult := runToolTest (ctx , method , customTool .Endpoint , headers , bodyParams )
485+ endpoint := resolveToolURL (
486+ customTool .Endpoint ,
487+ stringutils .JSONToStringMap (customTool .PathParams ),
488+ stringutils .JSONToInterfaceMap (customTool .QueryParams ),
489+ )
490+ testResult := runToolTest (ctx , method , endpoint , headers , bodyParams )
435491
436492 return & model.CustomToolTestResponse {
437493 Tool : response ,
438494 TestResult : testResult ,
439495 }, nil
440496}
497+
498+ // normalizeToolMethod upper-cases the method and enforces the allowlist, shared by
499+ // the saved-tool test and the stateless payload test so the two cannot drift.
500+ // Returns a BadRequest ApiError — an unsupported method is caller input, not a 500.
501+ func normalizeToolMethod (method string ) (string , error ) {
502+ upper := strings .ToUpper (strings .TrimSpace (method ))
503+ switch upper {
504+ case http .MethodGet , http .MethodPost , http .MethodPut , http .MethodDelete , http .MethodPatch , http .MethodHead , http .MethodOptions :
505+ return upper , nil
506+ default :
507+ return "" , apiErrors .New (
508+ apiErrors .InvalidInput ,
509+ fmt .Sprintf ("unsupported method: %s" , method ),
510+ http .StatusBadRequest ,
511+ )
512+ }
513+ }
514+
515+ // TestPayload runs the SSRF-hardened tool request against an UNSAVED payload —
516+ // powers the wizard's "test before save" (EVO-1738).
517+ func (s * customToolService ) TestPayload (ctx context.Context , req model.CustomToolTestPayloadRequest ) (* model.TestResult , error ) {
518+ method , err := normalizeToolMethod (req .Method )
519+ if err != nil {
520+ return nil , err
521+ }
522+
523+ endpoint := resolveToolURL (req .Endpoint , req .PathParams , req .QueryParams )
524+ return runToolTest (ctx , method , endpoint , req .Headers , req .BodyParams ), nil
525+ }
0 commit comments