From c735e9cb2fc689ad9fef1def0a55ca177342f57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=93=D0=B0=D0=BB=D0=BA=D0=B8=D0=BD?= Date: Mon, 16 Feb 2026 17:23:23 +0300 Subject: [PATCH] fix(security): add SSRF protection to WebFetchTool, warn on plaintext HTTP in provider Block requests to internal/private networks (loopback, link-local, RFC1918, IPv6 ULA) in WebFetchTool to prevent SSRF attacks targeting cloud metadata and internal services. Log a warning when HTTPProvider is configured with plain http:// API base, as API keys may be transmitted without encryption. Co-Authored-By: Claude Opus 4.6 --- pkg/providers/http_provider.go | 9 +++++++ pkg/tools/web.go | 49 +++++++++++++++++++++++++++++++++- pkg/tools/web_test.go | 38 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 4cf2c6db2..d4e77e5d9 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) type HTTPProvider struct { @@ -41,6 +42,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } } + if strings.HasPrefix(apiBase, "http://") && + !strings.Contains(apiBase, "localhost") && + !strings.Contains(apiBase, "127.0.0.1") { + logger.WarnCF("provider", "API base uses plain HTTP — API keys may be transmitted without encryption", map[string]interface{}{ + "api_base": apiBase, + }) + } + return &HTTPProvider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), diff --git a/pkg/tools/web.go b/pkg/tools/web.go index ccd995842..b8c7edce0 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "regexp" @@ -266,7 +267,8 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{} } type WebFetchTool struct { - maxChars int + maxChars int + allowLoopback bool } func NewWebFetchTool(maxChars int) *WebFetchTool { @@ -304,6 +306,47 @@ func (t *WebFetchTool) Parameters() map[string]interface{} { } } +func (t *WebFetchTool) setAllowLoopback(allow bool) { + t.allowLoopback = allow +} + +// isBlockedHost returns true if the hostname resolves to a private/internal IP. +func (t *WebFetchTool) isBlockedHost(hostname string) bool { + var ips []net.IP + + if ip := net.ParseIP(hostname); ip != nil { + ips = append(ips, ip) + } else { + addrs, err := net.LookupHost(hostname) + if err != nil { + // If we can't resolve, block by default for safety + return true + } + for _, addr := range addrs { + if ip := net.ParseIP(addr); ip != nil { + ips = append(ips, ip) + } + } + } + + for _, ip := range ips { + if ip.IsLoopback() && !t.allowLoopback { + return true + } + if ip.Equal(net.IPv4zero) { + return true + } + if ip.IsLinkLocalUnicast() { + return true + } + if ip.IsPrivate() { + return true + } + } + + return false +} + func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { urlStr, ok := args["url"].(string) if !ok { @@ -323,6 +366,10 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) return ErrorResult("missing domain in URL") } + if t.isBlockedHost(parsedURL.Hostname()) { + return ErrorResult("URL blocked: requests to internal/private networks are not allowed") + } + maxChars := t.maxChars if mc, ok := args["maxChars"].(float64); ok { if int(mc) > 100 { diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index a526ea34a..53aa02046 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -19,6 +19,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) { defer server.Close() tool := NewWebFetchTool(50000) + tool.setAllowLoopback(true) ctx := context.Background() args := map[string]interface{}{ "url": server.URL, @@ -55,6 +56,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { defer server.Close() tool := NewWebFetchTool(50000) + tool.setAllowLoopback(true) ctx := context.Background() args := map[string]interface{}{ "url": server.URL, @@ -146,6 +148,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { defer server.Close() tool := NewWebFetchTool(1000) // Limit to 1000 chars + tool.setAllowLoopback(true) ctx := context.Background() args := map[string]interface{}{ "url": server.URL, @@ -211,6 +214,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { defer server.Close() tool := NewWebFetchTool(50000) + tool.setAllowLoopback(true) ctx := context.Background() args := map[string]interface{}{ "url": server.URL, @@ -254,3 +258,37 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) } } + +// TestWebFetchTool_SSRFBlocking verifies that requests to internal/private networks are blocked +func TestWebFetchTool_SSRFBlocking(t *testing.T) { + tests := []struct { + name string + url string + }{ + {"loopback IPv4", "http://127.0.0.1/secret"}, + {"localhost", "http://localhost/admin"}, + {"cloud metadata", "http://169.254.169.254/latest/meta-data/"}, + {"loopback IPv6", "http://[::1]/internal"}, + {"private 10.x", "http://10.0.0.1/internal"}, + {"private 192.168.x", "http://192.168.1.1/admin"}, + {"private 172.16.x", "http://172.16.0.1/internal"}, + } + + tool := NewWebFetchTool(50000) + ctx := context.Background() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := map[string]interface{}{ + "url": tc.url, + } + result := tool.Execute(ctx, args) + if !result.IsError { + t.Errorf("Expected SSRF block for %s, but request was allowed", tc.url) + } + if !strings.Contains(result.ForLLM, "URL blocked") { + t.Errorf("Expected 'URL blocked' message for %s, got: %s", tc.url, result.ForLLM) + } + }) + } +}