mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-17 05:07:23 +08:00
Mirrors 14 merged upstream PRs into the Go agent port. PRs ported: - #15609 ExeSQL SSRF guard + DNS pin - #15436 HTTP timeout on external API tools - #16363 be_output restore + DeepL error path - #15644 switch no longer matches empty condition - #15374 session_id bind to path agent_id (DAO idor guard) - #16169 sandbox artifact ownership gate - #15457 tenant ownership on agentbots - #15145 rerun agent document access check - #15446 thinking switch (component portion; provider policy lives in internal/llm) - #15426 Invoke URL/proxy SSRF + DNS pin + no-redirects - #15238 agentbot thinking-logs beta endpoint - #14589 UserFillUp SSE event propagation - #14890 anonymous webhook opt-in - #15068 PipelineChunker new component (text/file_ref/parser_id dispatch; file-format extraction is a follow-up) 40 files, +2355 / -58 lines. 33 new tests, all targeted package suites pass (1721 + 4 skipped); 1 pre-existing flaky test unrelated.
193 lines
5.9 KiB
Go
193 lines
5.9 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 tool
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"ragflow/internal/utility"
|
|
)
|
|
|
|
// recordingDialer is an exesqlDialer that records the host it was
|
|
// asked to dial so SSRF-pinning tests can assert that the resolved
|
|
// public IP was substituted before connect.
|
|
type recordingDialer struct {
|
|
mu sync.Mutex
|
|
dialed []dialRecord
|
|
failFast bool
|
|
}
|
|
|
|
type dialRecord struct {
|
|
driver string
|
|
dsn string
|
|
}
|
|
|
|
func (r *recordingDialer) dial(driver, dsn string) (*sql.DB, error) {
|
|
r.mu.Lock()
|
|
r.dialed = append(r.dialed, dialRecord{driver: driver, dsn: dsn})
|
|
r.mu.Unlock()
|
|
if r.failFast {
|
|
return nil, errors.New("dialer: test stop before real DB I/O")
|
|
}
|
|
// Open a connection to a non-existent address — the test only
|
|
// cares about whether the SSRF guard accepted the host, not
|
|
// whether the connect succeeded. Returning a real *sql.DB
|
|
// prevents the InvokableRun defer from panicking on Close.
|
|
db, _ := sql.Open(driver, dsn)
|
|
return db, nil
|
|
}
|
|
|
|
// TestExeSQL_SSRF_RejectsLoopbackLinkLocalAndRFC1918 mirrors the
|
|
// Python test_exesql_ssrf.py rejection cases. Each non-public host
|
|
// must be rejected before any driver dispatch — InvokableRun must
|
|
// not call the dialer.
|
|
func TestExeSQL_SSRF_RejectsLoopbackLinkLocalAndRFC1918(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
host string
|
|
}{
|
|
{"loopback_ipv4", "127.0.0.1"},
|
|
{"loopback_alias", "localhost"},
|
|
{"cloud_metadata", "169.254.169.254"},
|
|
{"rfc1918_10", "10.0.0.1"},
|
|
{"rfc1918_192", "192.168.1.1"},
|
|
{"rfc1918_172", "172.16.0.1"},
|
|
{"unspecified", "0.0.0.0"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := &recordingDialer{}
|
|
e := NewExeSQLTool(exesqlConnParams{
|
|
DBType: "mysql", Host: c.host, Port: 3306,
|
|
Database: "d", Username: "u", Password: "p",
|
|
MaxRecords: 10,
|
|
}).WithExeSQLDialer(rec.dial)
|
|
|
|
_, err := e.InvokableRun(context.Background(), `{"sql":"SELECT 1"}`)
|
|
if err == nil {
|
|
t.Fatalf("expected SSRF rejection for %q, got nil", c.host)
|
|
}
|
|
if !errors.Is(err, ErrSSRFBlocked) {
|
|
t.Fatalf("err = %v, want ErrSSRFBlocked for host %q", err, c.host)
|
|
}
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
if len(rec.dialed) != 0 {
|
|
t.Fatalf("dialer should not be called when host is rejected; got %d calls", len(rec.dialed))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExeSQL_SSRF_RejectsEmptyHost covers the empty-string case which
|
|
// the loopback / link-local / RFC1918 cases do not exercise.
|
|
func TestExeSQL_SSRF_RejectsEmptyHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := &recordingDialer{}
|
|
e := NewExeSQLTool(exesqlConnParams{
|
|
DBType: "mysql", Host: " ", Port: 3306,
|
|
Database: "d", Username: "u", Password: "p",
|
|
MaxRecords: 10,
|
|
}).WithExeSQLDialer(rec.dial)
|
|
|
|
_, err := e.InvokableRun(context.Background(), `{"sql":"SELECT 1"}`)
|
|
if err == nil {
|
|
t.Fatal("expected SSRF rejection for empty host")
|
|
}
|
|
if !errors.Is(err, ErrSSRFBlocked) {
|
|
t.Fatalf("err = %v, want ErrSSRFBlocked", err)
|
|
}
|
|
if len(rec.dialed) != 0 {
|
|
t.Fatalf("dialer should not be called for empty host; got %d calls", len(rec.dialed))
|
|
}
|
|
}
|
|
|
|
// TestExeSQL_SSRF_PinsToValidatedIP ensures that when a DNS name
|
|
// resolves to a public IP, InvokableRun dials the validated IP, not
|
|
// the original hostname — closing the TOCTOU window for DNS
|
|
// rebinding. The resolver is stubbed via utility.LookupHost so the
|
|
// test does not depend on real DNS.
|
|
func TestExeSQL_SSRF_PinsToValidatedIP(t *testing.T) {
|
|
// Stub the resolver so example.test -> 1.2.3.4 (public, stable).
|
|
origLookup := utility.LookupHost
|
|
utility.LookupHost = func(host string) ([]string, error) {
|
|
if host == "example.test" {
|
|
return []string{"1.2.3.4"}, nil
|
|
}
|
|
return origLookup(host)
|
|
}
|
|
t.Cleanup(func() { utility.LookupHost = origLookup })
|
|
|
|
// failFast:true makes the test dialer return an error instead of
|
|
// opening a real *sql.DB, so the InvokableRun path stops after
|
|
// recording the DSN — no real TCP connect to 1.2.3.4:3306 is
|
|
// attempted (which would either succeed or hang on a TCP
|
|
// timeout, making the test slow and flaky on networks that
|
|
// reach Cloudflare). PR review round 6, Major #1.
|
|
rec := &recordingDialer{failFast: true}
|
|
e := NewExeSQLTool(exesqlConnParams{
|
|
DBType: "mysql", Host: "example.test", Port: 3306,
|
|
Database: "d", Username: "u", Password: "p",
|
|
MaxRecords: 10,
|
|
}).WithExeSQLDialer(rec.dial)
|
|
|
|
_, _ = e.InvokableRun(context.Background(), `{"sql":"SELECT 1"}`)
|
|
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
if len(rec.dialed) == 0 {
|
|
t.Fatal("dialer was not called; SSRF guard may have blocked a public host")
|
|
}
|
|
got := rec.dialed[0].dsn
|
|
if !strings.Contains(got, "1.2.3.4:3306") {
|
|
t.Fatalf("DSN should dial pinned IP 1.2.3.4, got %q", got)
|
|
}
|
|
if strings.Contains(got, "example.test") {
|
|
t.Fatalf("DSN should not contain original hostname, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestValidateDBHost_LiteralPublicIPAccepted: literal public IP is
|
|
// accepted without DNS lookup. Round-trips through net.ParseIP.
|
|
func TestValidateDBHost_LiteralPublicIPAccepted(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := ValidateDBHost("8.8.8.8")
|
|
if err != nil {
|
|
t.Fatalf("ValidateDBHost(8.8.8.8): %v", err)
|
|
}
|
|
if got != "8.8.8.8" {
|
|
t.Fatalf("got %q, want 8.8.8.8", got)
|
|
}
|
|
parsed := net.ParseIP(got)
|
|
if parsed == nil {
|
|
t.Fatalf("returned host %q is not a valid IP", got)
|
|
}
|
|
}
|