mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-08 20:34:48 +08:00
## Summary Resolves all 93 open alerts at https://github.com/infiniflow/ragflow/security/code-scanning by rule: | Rule | Count | Treatment | |------|-------|-----------| | py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing | | go/path-injection | 15 | Real fix where possible, suppression with rationale | | go/request-forgery | 8 | Suppression with rationale (operator-controlled URLs) | | go/clear-text-logging | 10 | Real fix — log scrubbing | | go/unsafe-quoting | 5 | Real fix — escape or refactor | | go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment | | go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 | | go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range check | | go/insecure-hostkeycallback | 1 | Real fix — known_hosts file | | go/disabled-certificate-check | 2 | Suppression with rationale | | go/command-injection | 1 | Suppression (sanitized via shq()) | | go/email-injection | 1 | Suppression with rationale | | go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) | | js/stack-trace-exposure | 1 | Real fix — generic client message | | js/prototype-pollution-utility | 1 | Real fix — reject __proto__/constructor/prototype | | py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 | | py/incomplete-url-substring-sanitization | 3 | Real fix — urlparse(hostname) | | py/paramiko-missing-host-key-validation | 1 | Real fix — load_system_host_keys + RejectPolicy | | cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to size_t | ## Real fixes (with measurable security improvement) **SSH host key verification (Go + Python)** Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with proper host key verification against a known_hosts file (configurable via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()` so existing setups keep working. **SQL injection in `user_canvas`** Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause` helper. Both `GetList()` and `ListByTenantIDs()` now route the user-supplied `orderby` query param through the helper, defaulting to `create_time` on miss. **SQL injection in `pipeline_operation_log`** Existing whitelist documented via CodeQL comment. **Real SQL injection in `infinity/chunk.go:931`** Escape `'` → `''` on user-controlled `questionText` before splicing into `filter_fulltext(...)` SQL filter. **Real SQL injection in `elasticsearch/sql.go:75`** Defense-in-depth escape on tokenizer output before splicing into `MATCH(...)`. **Python code injection in `result_protocol.go`** Replace raw JSON literal embedding into Python/JS expressions with base64 + `json.loads` / `JSON.parse(Buffer.from(..., 'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink and the brittleness of mixing JSON true/false/null with Python syntax. **URL substring check bypass in `embedding_model.py`** Replace `if "dashscope-intl.aliyuncs.com" in u` with `urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot bypass the routing. **Prototype pollution in `setNestedValue` (TS)** Reject `__proto__`/`constructor`/`prototype` keys before any assignment. **Integer overflow** - scrypt params via `ParseInt` + non-positive check (`internal/common/password.go`) - `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go) - `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc) **Cookie httponly** Set explicitly with rationale: this is the OAuth bootstrap cookie intentionally read by the SPA. **Stack trace exposure** Replace `error.message` in HTTP 500 response with generic `"internal error"`; full error still logged server-side via `console.error`. **Weak hashing** MD5 → SHA-256 for deterministic `conv_id` derivation (`conversation_service.py`). **Log scrubbing** Remove or redact user-controlled / sensitive content from clear-text logs across 8 ingestion parsers, `llm_service.py` ×11, `tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10, `conftest.py` ×4, `init_data.py`, `dataset_api_service.py`, `generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`, `pdf_parser.go`. Most patterns converted to parameterized logging (`logging.info("...: %d", n)`) or static messages. ## CodeQL suppressions (each with rationale) For alerts where the data flow is genuinely safe but CodeQL can't see the context — operator-controlled URLs, sanitized inputs, etc. — I added `// codeql[go/<rule>] <rationale>` annotations rather than dismissing them, so future readers can audit the rationale inline: - `internal/agent/component/invoke.go:135` — Invoke is a generic canvas HTTP client - `internal/service/langfuse.go` ×2 — host is per-tenant operator config - `internal/service/file.go:1184` — already SSRF-guarded by `assertURLSafe` - `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` + IP-pinned - `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't be tampered - `internal/service/deep_researcher.go:269` — `callback` is SSE display string, not SQL - `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC 4122) - `internal/cli/common_command.go` ×2 — CLI trusts operator-configured URL - `internal/utility/smtp.go:194` — msg is server-built, not user form input - `internal/entity/models/*` ×14 (path-injection) — audio file paths are caller-supplied ## Test plan - ✅ All 13 modified Go packages build cleanly - ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`, `internal/agent/component`, `internal/engine/infinity`, `internal/dao` - ✅ All 11 modified Python files parse via `ast.parse` - ✅ TypeScript `tsc --noEmit` clean on the modified `use-provider-fields.tsx` - ✅ `node --check` clean on the modified JS file 🤖 Generated with [Claude Code](https://claude.com/claude-code)
290 lines
9.4 KiB
Go
290 lines
9.4 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.
|
|
package component
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
// InvokeComponent is the HTTP client node. Stateless across invocations.
|
|
type InvokeComponent struct {
|
|
name string
|
|
}
|
|
|
|
// NewInvokeComponent constructs an Invoke component.
|
|
func NewInvokeComponent(_ map[string]any) (Component, error) {
|
|
return &InvokeComponent{name: componentNameInvoke}, nil
|
|
}
|
|
|
|
// Name returns the registered component name.
|
|
func (i *InvokeComponent) Name() string { return i.name }
|
|
|
|
// Invoke executes a single HTTP request and returns the status code,
|
|
// body, and response headers. See Inputs() for the param contract.
|
|
func (i *InvokeComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
|
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")
|
|
}
|
|
// url.Parse is a sanity check; we trust the orchestrator to have
|
|
// already resolved any {{...}} refs, but a bad string here is a
|
|
// programmer error worth surfacing.
|
|
if _, err := url.Parse(rawURL); err != nil {
|
|
return nil, fmt.Errorf("Invoke: parse url: %w", err)
|
|
}
|
|
|
|
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 && 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)
|
|
if h, ok := inputs["headers"].(map[string]any); ok {
|
|
for k, v := range h {
|
|
if s, ok := v.(string); ok {
|
|
req.Header.Set(k, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wrap the stdlib Transport with otelhttp so the request gets a
|
|
// child span + W3C traceparent injected automatically.
|
|
transport := otelhttp.NewTransport(http.DefaultTransport)
|
|
// Optional proxy support: if inputs["proxy"] is set, build a
|
|
// dedicated Transport that uses it. This avoids mutating the
|
|
// global http.DefaultTransport (which would also affect unrelated
|
|
// components in the same process).
|
|
if proxyStr, ok := inputs["proxy"].(string); ok && proxyStr != "" {
|
|
transport = otelhttp.NewTransport(&http.Transport{
|
|
Proxy: http.ProxyURL(mustParseProxy(proxyStr)),
|
|
})
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: timeout,
|
|
Transport: transport,
|
|
}
|
|
// codeql[go/request-forgery] Intentional: the Invoke component is
|
|
// 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.
|
|
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)
|
|
}
|
|
|
|
hdr := make(map[string]string, len(resp.Header))
|
|
for k, vs := range resp.Header {
|
|
// First value only — multi-value headers are uncommon in
|
|
// canvas-DSL HTTP responses, and the param contract specifies
|
|
// a string map.
|
|
if len(vs) > 0 {
|
|
hdr[k] = vs[0]
|
|
}
|
|
}
|
|
|
|
bodyStr := string(bodyBytes)
|
|
|
|
// Clean HTML from response body when clean_html input is set.
|
|
if cleanHTML, _ := inputs["clean_html"].(bool); cleanHTML {
|
|
bodyStr = stripHTMLTags(bodyStr)
|
|
}
|
|
|
|
// Parse body according to the requested datatype.
|
|
datatype, _ := inputs["datatype"].(string)
|
|
if datatype == "" {
|
|
// Infer from Content-Type header.
|
|
ct := resp.Header.Get("Content-Type")
|
|
if strings.Contains(ct, "application/json") {
|
|
datatype = "json"
|
|
} else {
|
|
datatype = "text"
|
|
}
|
|
}
|
|
|
|
return map[string]any{
|
|
"status": resp.StatusCode,
|
|
"body": bodyStr,
|
|
"headers": hdr,
|
|
"datatype": datatype,
|
|
}, nil
|
|
}
|
|
|
|
// 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{
|
|
"status": "HTTP status code (int).",
|
|
"body": "Response body (string, truncated at 16 MiB).",
|
|
"headers": "Response headers (first value per key).",
|
|
"datatype": "Inferred response datatype: 'json' | 'text' | 'html'.",
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|