fix the issues reported by Claude Code

This commit is contained in:
chengpeng-wang 2026-03-03 06:02:55 -05:00
parent 094c6cf134
commit 9c39745e49
2 changed files with 124 additions and 1 deletions

View file

@ -546,6 +546,13 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
} }
return nil return nil
} }
if proxy == "" {
client.Transport.(*http.Transport).DialContext = newSafeDialer(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
})
}
if fetchLimitBytes <= 0 { if fetchLimitBytes <= 0 {
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
} }
@ -587,7 +594,7 @@ func blockPrivateTarget(ctx context.Context, parsedURL *url.URL) error {
hostname := parsedURL.Hostname() // strips port and IPv6 brackets hostname := parsedURL.Hostname() // strips port and IPv6 brackets
addrs, err := net.DefaultResolver.LookupHost(ctx, hostname) addrs, err := net.DefaultResolver.LookupHost(ctx, hostname)
if err != nil { if err != nil {
return fmt.Errorf("could not resolve host %q", hostname) return fmt.Errorf("DNS resolution failed for host %q: %w", hostname, err)
} }
for _, addr := range addrs { for _, addr := range addrs {
ip := net.ParseIP(addr) ip := net.ParseIP(addr)
@ -602,6 +609,39 @@ func blockPrivateTarget(ctx context.Context, parsedURL *url.URL) error {
return nil return nil
} }
func newSafeDialer(base *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, fmt.Errorf("DNS resolution failed for host %q: %w", host, err)
}
for _, a := range addrs {
ip := net.ParseIP(a)
if ip == nil {
continue
}
if ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return nil, fmt.Errorf("requests to private/internal addresses are not allowed")
}
}
// Connect directly to the resolved IP to prevent re-resolution at dial time.
var lastErr error
for _, a := range addrs {
conn, err := base.DialContext(ctx, network, net.JoinHostPort(a, port))
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
}
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
urlStr, ok := args["url"].(string) urlStr, ok := args["url"].(string)
if !ok { if !ok {

View file

@ -6,8 +6,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -699,3 +701,84 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
} }
} }
func TestBlockPrivateTarget(t *testing.T) {
ctx := context.Background()
blocked := []struct {
name string
raw string
}{
{"loopback IPv4", "http://127.0.0.1/"},
{"RFC1918 10/8", "http://10.0.0.1/"},
{"RFC1918 172.16/12", "http://172.16.0.1/"},
{"RFC1918 192.168/16", "http://192.168.1.1/"},
{"link-local", "http://169.254.1.1/"},
{"unspecified", "http://0.0.0.0/"},
{"loopback IPv6", "http://[::1]/"},
{"link-local IPv6", "http://[fe80::1]/"},
}
for _, tc := range blocked {
t.Run(tc.name, func(t *testing.T) {
u, _ := url.Parse(tc.raw)
if err := blockPrivateTarget(ctx, u); err == nil {
t.Errorf("expected blockPrivateTarget to block %s, but it was allowed", tc.raw)
}
})
}
allowed := []struct {
name string
raw string
}{
{"public DNS", "http://8.8.8.8/"},
{"RFC5737 doc range", "http://192.0.2.1/"},
}
for _, tc := range allowed {
t.Run(tc.name, func(t *testing.T) {
u, _ := url.Parse(tc.raw)
if err := blockPrivateTarget(ctx, u); err != nil {
t.Errorf("expected blockPrivateTarget to allow %s, got error: %v", tc.raw, err)
}
})
}
}
func TestSafeDialer(t *testing.T) {
base := &net.Dialer{Timeout: 2 * time.Second}
dial := newSafeDialer(base)
ctx := context.Background()
privateAddrs := []struct {
name string
addr string
}{
{"loopback", "127.0.0.1:80"},
{"RFC1918 10/8", "10.0.0.1:80"},
{"RFC1918 192.168/16", "192.168.1.1:80"},
}
for _, tc := range privateAddrs {
t.Run("blocks "+tc.name, func(t *testing.T) {
_, err := dial(ctx, "tcp", tc.addr)
if err == nil {
t.Fatalf("expected error for private addr %s, got nil", tc.addr)
}
if !strings.Contains(err.Error(), "private/internal") {
t.Errorf("expected SSRF error for %s, got: %v", tc.addr, err)
}
})
}
// 192.0.2.1 (RFC 5737) is public — SSRF check passes, connection fails at
// the network level (unreachable host), NOT with an SSRF error.
t.Run("allows public RFC5737 IP", func(t *testing.T) {
_, err := dial(ctx, "tcp", "192.0.2.1:80")
if err == nil {
// Unexpected success — the IP is supposed to be unreachable.
return
}
if strings.Contains(err.Error(), "private/internal") {
t.Errorf("192.0.2.1 should not be blocked as private, got: %v", err)
}
})
}