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 <noreply@anthropic.com>
This commit is contained in:
Александр Галкин 2026-02-16 17:23:23 +03:00
parent 4dfb331560
commit c735e9cb2f
3 changed files with 95 additions and 1 deletions

View file

@ -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, "/"),

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@ -267,6 +268,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
type WebFetchTool struct {
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 {

View file

@ -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)
}
})
}
}