package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" ) const ( umindWebhookTimeout = 10 * time.Second umindWebhookRespuestaLimit = 256 * 1024 // 256KB ) // validarURLTool solo chequea forma (https + host presente) antes de // intentar la llamada — la validación real de destino pasa por // dialContextSeguro en cada conexión, no acá, para no dejar una ventana // entre "resolver y validar" y "conectar" (DNS rebinding: el mismo hostname // podría resolver a una IP pública en el primer lookup y a una interna // milisegundos después, en el connect real). func validarURLTool(rawURL string) (*url.URL, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return nil, fmt.Errorf("URL inválida: %w", err) } if u.Scheme != "https" { return nil, fmt.Errorf("la URL de la tool debe ser https") } if u.Hostname() == "" { return nil, fmt.Errorf("URL sin host") } return u, nil } // dialContextSeguro resuelve el host en el momento de conectar (no antes) y // rechaza cualquier IP interna justo antes de abrir la conexión TCP — cierra // la ventana de DNS rebinding que tendría validar la URL una vez y confiar // en que el cliente HTTP resuelva "lo mismo" después. func dialContextSeguro(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) if err != nil { return nil, fmt.Errorf("no se pudo resolver %s: %w", host, err) } if len(ips) == 0 { return nil, fmt.Errorf("%s no resolvió a ninguna IP", host) } for _, ip := range ips { if ipEsInterna(ip) { return nil, fmt.Errorf("%s resuelve a una IP interna (%s), no permitido", host, ip) } } dialer := &net.Dialer{Timeout: umindWebhookTimeout} return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) } // ipEsInterna centraliza qué se considera "red interna" — separado para // poder testearlo sin red real. func ipEsInterna(ip net.IP) bool { return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() } var umindWebhookHTTPClient = &http.Client{ Timeout: umindWebhookTimeout, Transport: &http.Transport{ DialContext: dialContextSeguro, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { return fmt.Errorf("redirects no permitidos en tools custom") }, } // LlamarHerramientaWebhook ejecuta una tool custom: POST a la URL configurada // con los argumentos que decidió el modelo, con guardas SSRF y límites de // tiempo/tamaño de respuesta. Devuelve el body de la respuesta tal cual (el // modelo lo interpreta como resultado de la tool). func LlamarHerramientaWebhook(rawURL string, headerNombre, headerValor string, argumentos map[string]interface{}) (string, error) { u, err := validarURLTool(rawURL) if err != nil { return "", err } body, err := json.Marshal(argumentos) if err != nil { return "", fmt.Errorf("no se pudieron serializar los argumentos: %w", err) } req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") if strings.TrimSpace(headerNombre) != "" { req.Header.Set(headerNombre, headerValor) } resp, err := umindWebhookHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("no se pudo contactar la tool: %w", err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, umindWebhookRespuestaLimit)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { detalle := strings.TrimSpace(string(raw)) if len(detalle) > 500 { detalle = detalle[:500] } return "", fmt.Errorf("la tool respondió %d: %s", resp.StatusCode, detalle) } return string(raw), nil }