mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-15 01:18:26 +08:00
## Summary - Add the GitHub Canvas component with tool registration and reference propagation. - Align the Invoke component with the Python contract for node config, input form, response output, and timing fields. - GitHub search and HTTP Invoke now work correctly in the Go Canvas runtime. ## Tests - `bash build.sh --test ./internal/agent/tool/...` - `bash build.sh --test ./internal/agent/component/...` Note: the untracked go_ragflow_cli file is not part of the PR changes. <img width="1813" height="1102" alt="image" src="https://github.com/user-attachments/assets/f69cef32-59a0-4287-a06b-6843d85198cf" /> <img width="1813" height="1102" alt="image" src="https://github.com/user-attachments/assets/b37dfc31-bc9b-4937-a38e-d2184bb157fe" />
501 lines
17 KiB
Go
501 lines
17 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
// Package component — Invoke component (T3).
|
|
//
|
|
// Invoke is the canvas HTTP client node. It supports GET/POST/
|
|
// PUT/DELETE with custom headers, optional proxy, and per-request
|
|
// timeout, and wraps the underlying net/http.Transport with
|
|
// go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
|
|
// .NewTransport so outbound calls automatically propagate W3C
|
|
// traceparent headers.
|
|
//
|
|
// SSRF guard (PR #15426): every outbound URL is validated against
|
|
// the shared utility.AssertURLSafe before any network I/O. Both the
|
|
// target URL and an optional proxy URL are checked — the proxy
|
|
// vector matters because the Go transport hands the request to the
|
|
// proxy host, which would otherwise re-resolve the original host
|
|
// and re-open the rebinding window the SSRF guard just closed. To
|
|
// defeat DNS rebinding the transport dials the validated public IP
|
|
// directly (utility.PinnedHTTPClient) and we disable redirect
|
|
// following so a 30x to a private host cannot bypass the guard.
|
|
package component
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
"go.uber.org/zap"
|
|
|
|
"ragflow/internal/utility"
|
|
)
|
|
|
|
const (
|
|
componentNameInvoke = "Invoke"
|
|
|
|
defaultInvokeTimeout = 30 * time.Second
|
|
defaultInvokeUserAgent = "ragflow-agent/1.0 (Invoke component)"
|
|
defaultInvokeContentCT = "application/json"
|
|
maxInvokeResponseBody = 16 << 20 // 16 MiB; hard cap to avoid OOM
|
|
)
|
|
|
|
// invokeClockOrigin gives Invoke's _created_time the same monotonic-clock
|
|
// semantics as Python's time.perf_counter(). Its absolute value is process
|
|
// local; only elapsed durations are meaningful.
|
|
var invokeClockOrigin = time.Now()
|
|
|
|
// InvokeComponent is the HTTP client node. Its node configuration is immutable
|
|
// across invocations.
|
|
type InvokeComponent struct {
|
|
name string
|
|
params map[string]any
|
|
}
|
|
|
|
// NewInvokeComponent constructs an Invoke component.
|
|
func NewInvokeComponent(params map[string]any) (Component, error) {
|
|
return &InvokeComponent{name: componentNameInvoke, params: cloneInvokeParams(params)}, nil
|
|
}
|
|
|
|
// Name returns the registered component name.
|
|
func (i *InvokeComponent) Name() string { return i.name }
|
|
|
|
// Invoke executes a single HTTP request and returns its response text as
|
|
// `result`, matching the Python Invoke component. See Inputs() for the
|
|
// param contract.
|
|
//
|
|
// SSRF flow (PR #15426):
|
|
// 1. Validate the target URL via utility.AssertURLSafe (loopback /
|
|
// link-local / RFC1918 / metadata / unresolvable are rejected).
|
|
// 2. Validate the optional proxy URL the same way (the proxy
|
|
// re-resolves the target host; an unsafe proxy would defeat
|
|
// step 1).
|
|
// 3. Use utility.PinnedHTTPClient to dial the validated public IP
|
|
// for the target host — closing the TOCTOU window between
|
|
// validation and connect.
|
|
// 4. Disable redirect following so a 30x to a private host cannot
|
|
// silently bypass the guard.
|
|
//
|
|
// On any of those checks failing the function returns an `_ERROR`
|
|
// output (no Go error) so the canvas can route around the failure
|
|
// the same way the Python fix does, instead of crashing the node.
|
|
func (i *InvokeComponent) Invoke(ctx context.Context, inputs map[string]any) (output map[string]any, invokeErr error) {
|
|
startedAt := time.Now()
|
|
defer func() {
|
|
if output == nil {
|
|
return
|
|
}
|
|
output["_created_time"] = startedAt.Sub(invokeClockOrigin).Seconds()
|
|
output["_elapsed_time"] = time.Since(startedAt).Seconds()
|
|
}()
|
|
|
|
inputs = i.mergeInputs(inputs)
|
|
method, _ := inputs["method"].(string)
|
|
method = strings.ToUpper(strings.TrimSpace(method))
|
|
switch method {
|
|
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete:
|
|
default:
|
|
return nil, fmt.Errorf("Invoke: invalid method %q (want GET/POST/PUT/DELETE)", method)
|
|
}
|
|
rawURL, _ := inputs["url"].(string)
|
|
if rawURL == "" {
|
|
return nil, errors.New("Invoke: url is required")
|
|
}
|
|
// Bare hostnames (no scheme) are rejected — the Python fix
|
|
// prefixes "http://" before validating, but the Go side treats
|
|
// the absence of a scheme as a programmer error so a canvas
|
|
// author must be explicit. url.Parse is a sanity check; we
|
|
// trust the orchestrator to have already resolved any
|
|
// {{...}} refs.
|
|
if _, err := url.Parse(rawURL); err != nil {
|
|
return nil, fmt.Errorf("Invoke: parse url: %w", err)
|
|
}
|
|
|
|
// Step 1: SSRF guard for the target URL. The validated
|
|
// hostname + resolved public IP are reused for DNS pinning.
|
|
host, pinnedIP, err := utility.AssertURLSafe(rawURL)
|
|
if err != nil {
|
|
return invokeSSRFError("url", rawURL, err), nil
|
|
}
|
|
|
|
// Step 2: SSRF guard for the proxy URL (if configured).
|
|
// Mirrors the Python assert_url_is_safe(proxy_url) check.
|
|
var (
|
|
proxyHost string
|
|
proxyIP string
|
|
)
|
|
proxyStr, _ := inputs["proxy"].(string)
|
|
if proxyStr != "" {
|
|
// Fail-closed target check (PR #15426 round-2 review).
|
|
//
|
|
// When a proxy is configured, Go dials the proxy host and then
|
|
// forwards the request URL — including the target hostname —
|
|
// through the proxy. Go does NOT dial the target itself, so
|
|
// our pinned-IP DialContext only protects the proxy→us hop.
|
|
// The proxy performs its own DNS resolution for the target
|
|
// hostname at connect time, which re-opens the
|
|
// SSRF/DNS-rebinding window the SSRF guard just closed.
|
|
//
|
|
// The safe fix is to refuse hostname targets in proxy mode:
|
|
// a literal-IP target cannot rebind (there is nothing to
|
|
// resolve), so the proxy either relays the IP as-is or
|
|
// refuses — either way we have not given it a window to
|
|
// mis-resolve. Hostname targets must be sent direct (no
|
|
// proxy) so our PinnedHTTPClient can pin the dial.
|
|
//
|
|
// The Python reference accepted this trade-off for proxy mode
|
|
// in PR #15426 (it also has no way to constrain the
|
|
// proxy's resolution); we make it explicit at the Invoke
|
|
// layer so a caller cannot accidentally rely on the guard
|
|
// for a hostname+proxy combination.
|
|
if net.ParseIP(host) == nil {
|
|
return invokeSSRFError("url", rawURL,
|
|
fmt.Errorf("Invoke: proxy mode requires a literal-IP target URL (hostnames are unsafe because the proxy re-resolves them)")), nil
|
|
}
|
|
ph, pip, perr := utility.AssertURLSafe(proxyStr)
|
|
if perr != nil {
|
|
return invokeSSRFError("proxy", proxyStr, perr), nil
|
|
}
|
|
proxyHost, proxyIP = ph, pip
|
|
}
|
|
|
|
timeout := defaultInvokeTimeout
|
|
if v, ok := inputs["timeout"].(int); ok && v > 0 {
|
|
timeout = time.Duration(v) * time.Second
|
|
} else if v, ok := inputs["timeout"].(float64); ok && v > 0 {
|
|
timeout = time.Duration(v) * time.Second
|
|
}
|
|
|
|
contentType, _ := inputs["content_type"].(string)
|
|
if contentType == "" && (method == http.MethodPost || method == http.MethodPut) {
|
|
contentType = defaultInvokeContentCT
|
|
}
|
|
|
|
var body io.Reader
|
|
if s, ok := inputs["body"].(string); ok != false && s != "" {
|
|
body = bytes.NewReader([]byte(s))
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Invoke: build request: %w", err)
|
|
}
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
req.Header.Set("User-Agent", defaultInvokeUserAgent)
|
|
headers, err := invokeHeaders(inputs["headers"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range headers {
|
|
if s, ok := v.(string); ok {
|
|
req.Header.Set(k, s)
|
|
}
|
|
}
|
|
|
|
// Step 3: build the client. When a proxy is configured, the
|
|
// Go transport dials the proxy host using its own dialer,
|
|
// which would re-resolve the proxy hostname at connect time
|
|
// and re-open the rebinding window the SSRF guard just
|
|
// closed. We pin the proxy dial by wrapping a custom
|
|
// DialContext that intercepts the proxy-host dial and
|
|
// replaces the target with the validated proxy IP. The
|
|
// underlying TCP connection thus goes to the IP we
|
|
// validated, even if a subsequent DNS lookup returns a
|
|
// different answer (TOCTOU). The validated IP is captured
|
|
// from the proxy SSRF check above (proxyIP).
|
|
var client *http.Client
|
|
if proxyStr != "" {
|
|
proxyURL := mustParseProxy(proxyStr)
|
|
pinnedProxyDialer := &net.Dialer{
|
|
Timeout: timeout,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
client = &http.Client{
|
|
Timeout: timeout,
|
|
Transport: otelhttp.NewTransport(&http.Transport{
|
|
Proxy: http.ProxyURL(proxyURL),
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
// When the transport dials the proxy, addr is
|
|
// "<proxy_host>:<proxy_port>". Replace the
|
|
// host with the validated public IP while
|
|
// keeping the original port. Any other dial
|
|
// (e.g. a redirect hop the no-redirect policy
|
|
// would have blocked) falls through to the
|
|
// default dialer.
|
|
host, port, splitErr := net.SplitHostPort(addr)
|
|
if splitErr != nil || host != proxyURL.Hostname() || proxyIP == "" {
|
|
return pinnedProxyDialer.DialContext(ctx, network, addr)
|
|
}
|
|
return pinnedProxyDialer.DialContext(ctx, network, net.JoinHostPort(proxyIP, port))
|
|
},
|
|
TLSHandshakeTimeout: timeout,
|
|
ResponseHeaderTimeout: timeout,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
ForceAttemptHTTP2: false,
|
|
}),
|
|
CheckRedirect: noRedirects,
|
|
}
|
|
} else {
|
|
// Direct path: pin to the validated public IP, disable
|
|
// redirects, and apply the OTel transport. PinnedHTTPClient
|
|
// sets its own timeout; we re-wrap with otelhttp so the
|
|
// request gets a child span + W3C traceparent injected.
|
|
pinned := utility.PinnedHTTPClient(host, pinnedIP, timeout)
|
|
pinned.Transport = otelhttp.NewTransport(pinned.Transport)
|
|
pinned.CheckRedirect = noRedirects
|
|
client = pinned
|
|
}
|
|
_ = proxyHost
|
|
_ = proxyIP
|
|
|
|
// a generic HTTP client node in the canvas DSL — operators wire it
|
|
// to arbitrary endpoints. SSRF surface is limited to operators
|
|
// (not end users), and outbound traffic is rate-limited by the
|
|
// client timeout + maxInvokeResponseBody cap above.
|
|
// codeql[go/request-forgery] Intentional: the Invoke component is
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Invoke: do: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Cap the response body to keep a hostile server from streaming
|
|
// infinite bytes into memory.
|
|
limited := io.LimitReader(resp.Body, maxInvokeResponseBody)
|
|
bodyBytes, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Invoke: read body: %w", err)
|
|
}
|
|
|
|
bodyStr := string(bodyBytes)
|
|
|
|
// Clean HTML from response body when clean_html input is set.
|
|
if cleanHTML, _ := inputs["clean_html"].(bool); cleanHTML {
|
|
bodyStr = stripHTMLTags(bodyStr)
|
|
}
|
|
|
|
return map[string]any{
|
|
"result": bodyStr,
|
|
}, nil
|
|
}
|
|
|
|
// GetInputForm returns the variables an Invoke node accepts from the
|
|
// surrounding canvas. The HTTP method, URL, headers, and timeout are node
|
|
// configuration, while variables are supplied at run time.
|
|
func (i *InvokeComponent) GetInputForm() map[string]any {
|
|
form := make(map[string]any)
|
|
variables, _ := i.params["variables"].([]any)
|
|
for _, raw := range variables {
|
|
variable, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ref := strings.TrimSpace(stringParam(variable["ref"]))
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(stringParam(variable["key"]))
|
|
if name == "" {
|
|
name = ref
|
|
}
|
|
form[ref] = map[string]any{"type": "line", "name": name}
|
|
}
|
|
return form
|
|
}
|
|
|
|
func (i *InvokeComponent) mergeInputs(inputs map[string]any) map[string]any {
|
|
merged := cloneInvokeParams(i.params)
|
|
for key, value := range inputs {
|
|
if _, configured := merged[key]; !configured {
|
|
merged[key] = value
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func cloneInvokeParams(params map[string]any) map[string]any {
|
|
cloned := make(map[string]any, len(params))
|
|
for key, value := range params {
|
|
cloned[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func invokeHeaders(raw any) (map[string]any, error) {
|
|
if raw == nil {
|
|
return nil, nil
|
|
}
|
|
if headers, ok := raw.(map[string]any); ok {
|
|
return headers, nil
|
|
}
|
|
text, ok := raw.(string)
|
|
if !ok || strings.TrimSpace(text) == "" {
|
|
return nil, nil
|
|
}
|
|
var headers map[string]any
|
|
if err := json.Unmarshal([]byte(text), &headers); err != nil {
|
|
return nil, fmt.Errorf("Invoke: headers must be a JSON object: %w", err)
|
|
}
|
|
if headers == nil {
|
|
return nil, errors.New("Invoke: headers must be a JSON object")
|
|
}
|
|
return headers, nil
|
|
}
|
|
|
|
// invokeSSRFError builds the _ERROR output the canvas uses to route
|
|
// around a refused URL. We mirror the Python message verbatim
|
|
// ("URL not valid") so downstream consumers that key on the string
|
|
// keep working after the port.
|
|
func invokeSSRFError(kind, raw string, err error) map[string]any {
|
|
zap.L().Warn("Invoke SSRF guard blocked request",
|
|
zap.String("kind", kind),
|
|
zap.String("url", sanitizeLogURL(raw)),
|
|
zap.Error(err),
|
|
)
|
|
return map[string]any{
|
|
"_ERROR": "URL not valid",
|
|
"result": nil,
|
|
}
|
|
}
|
|
|
|
// noRedirects is the http.Client.CheckRedirect value that matches
|
|
// the python requests `allow_redirects=False` semantics — a 30x is
|
|
// returned to the caller as a normal response (with the Location
|
|
// header) instead of being followed. Without this, a 302 to a
|
|
// private host would silently bypass the SSRF guard above.
|
|
func noRedirects(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
// sanitizeLogURL redacts the path / query from a URL so error logs
|
|
// don't echo operator-configured tokens (e.g. an API key passed as
|
|
// a path component).
|
|
func sanitizeLogURL(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return "<invalid-url>"
|
|
}
|
|
return u.Scheme + "://" + u.Host
|
|
}
|
|
|
|
// Stream is a synchronous facade over Invoke. Real streaming
|
|
// (chunked transfer as it arrives) is a future enhancement.
|
|
func (i *InvokeComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
|
out, err := i.Invoke(ctx, inputs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ch := make(chan map[string]any, 1)
|
|
ch <- out
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// Inputs returns the public parameter surface.
|
|
func (i *InvokeComponent) Inputs() map[string]string {
|
|
return map[string]string{
|
|
"method": "HTTP method: GET, POST, PUT, or DELETE (case-insensitive).",
|
|
"url": "Target URL; can be a {{...}} reference resolved upstream.",
|
|
"headers": "Optional map of string headers.",
|
|
"body": "Optional request body (string).",
|
|
"timeout": "Per-request timeout in seconds; default 30.",
|
|
"proxy": "Optional proxy URL (e.g. http://host:3128).",
|
|
"content_type": "Optional Content-Type; default 'application/json' for POST/PUT.",
|
|
"clean_html": "When true, strip HTML tags from the response body.",
|
|
"datatype": "Expected response datatype: 'json', 'text', or 'html'. Default 'json'.",
|
|
"variables": "Optional template variables for URL/body interpolation.",
|
|
}
|
|
}
|
|
|
|
// Outputs returns the response surface.
|
|
func (i *InvokeComponent) Outputs() map[string]string {
|
|
return map[string]string{
|
|
"result": "Response body as text.",
|
|
}
|
|
}
|
|
|
|
// mustParseProxy parses a proxy URL string. We keep this helper here
|
|
// (rather than calling url.Parse inline) so the panic-on-bad-input
|
|
// behavior is uniform across the package — proxy strings are operator-
|
|
// configured, a malformed one is a deployment error worth crashing
|
|
// loud on.
|
|
func mustParseProxy(raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Invoke: invalid proxy URL %q: %v", raw, err))
|
|
}
|
|
// Defensive check: net/http.ProxyURL will silently no-op on a
|
|
// URL with no Host. Surface a clear panic instead.
|
|
if u.Host == "" {
|
|
panic(fmt.Sprintf("Invoke: proxy URL %q has no host", raw))
|
|
}
|
|
return u
|
|
}
|
|
|
|
// stripHTMLTags removes HTML tags from the input string. This is a
|
|
// best-effort implementation — it uses a simple regexp to remove
|
|
// everything between < and >. It is NOT a full HTML sanitizer and
|
|
// should only be used for cleaning up response text for consumption
|
|
// by downstream LLM nodes.
|
|
// Mirrors Python's `strip_html_tags` helper (invoke.py).
|
|
func stripHTMLTags(s string) string {
|
|
// Simple regexp-based approach: remove everything between < and >
|
|
re := strings.NewReplacer(
|
|
"<script", "\n<script",
|
|
"</script>", "</script>\n",
|
|
"<style", "\n<style",
|
|
"</style>", "</style>\n",
|
|
)
|
|
s = re.Replace(s)
|
|
for {
|
|
start := strings.Index(s, "<")
|
|
if start == -1 {
|
|
break
|
|
}
|
|
end := strings.Index(s[start:], ">")
|
|
if end == -1 {
|
|
break
|
|
}
|
|
s = s[:start] + s[start+end+1:]
|
|
}
|
|
// Collapse multiple newlines
|
|
for strings.Contains(s, "\n\n\n") {
|
|
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// netHTTPImports is a no-op reference to keep `net` in the import set
|
|
// for go vet's unused-import check while the production code path
|
|
// doesn't otherwise need the net package (only used by the optional
|
|
// proxy path via http.ProxyURL).
|
|
var _ = net.IPv4len
|
|
|
|
func init() {
|
|
Register(componentNameInvoke, NewInvokeComponent)
|
|
}
|