2026-02-04 11:06:13 +00:00
package tools
import (
2026-02-22 16:30:14 +00:00
"bytes"
2026-02-04 11:06:13 +00:00
"context"
"encoding/json"
2026-02-27 17:56:02 +00:00
"errors"
2026-02-04 11:06:13 +00:00
"fmt"
"io"
2026-03-15 21:12:03 +00:00
"mime"
2026-03-11 11:22:20 +00:00
"net"
2026-02-04 11:06:13 +00:00
"net/http"
"net/url"
"regexp"
"strings"
2026-03-10 08:34:11 +00:00
"sync/atomic"
2026-02-04 11:06:13 +00:00
"time"
2026-03-13 06:04:02 +00:00
2026-03-19 09:01:45 +00:00
"github.com/sipeed/picoclaw/pkg/config"
2026-03-17 16:14:23 +00:00
"github.com/sipeed/picoclaw/pkg/logger"
2026-03-13 06:04:02 +00:00
"github.com/sipeed/picoclaw/pkg/utils"
2026-02-04 11:06:13 +00:00
)
const (
2026-03-19 09:01:45 +00:00
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
userAgentHonest = "picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)"
2026-03-01 05:55:46 +00:00
// HTTP client timeouts for web tool providers.
searchTimeout = 10 * time . Second // Brave, Tavily, DuckDuckGo
perplexityTimeout = 30 * time . Second // Perplexity (LLM-based, slower)
fetchTimeout = 60 * time . Second // WebFetchTool
defaultMaxChars = 50000
maxRedirects = 5
2026-02-04 11:06:13 +00:00
)
2026-02-26 09:44:03 +00:00
// Pre-compiled regexes for HTML text extraction
var (
reScript = regexp . MustCompile ( ` <script[\s\S]*?</script> ` )
reStyle = regexp . MustCompile ( ` <style[\s\S]*?</style> ` )
reTags = regexp . MustCompile ( ` <[^>]+> ` )
reWhitespace = regexp . MustCompile ( ` [^\S\n]+ ` )
reBlankLines = regexp . MustCompile ( ` \n { 3,} ` )
// DuckDuckGo result extraction
reDDGLink = regexp . MustCompile ( ` <a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a> ` )
reDDGSnippet = regexp . MustCompile ( ` <a class="result__snippet[^"]*".*?>([\s\S]*?)</a> ` )
2026-02-04 11:06:13 +00:00
)
2026-03-10 08:34:11 +00:00
type APIKeyPool struct {
keys [ ] string
current uint32
}
func NewAPIKeyPool ( keys [ ] string ) * APIKeyPool {
return & APIKeyPool {
keys : keys ,
}
}
type APIKeyIterator struct {
pool * APIKeyPool
startIdx uint32
attempt uint32
}
func ( p * APIKeyPool ) NewIterator ( ) * APIKeyIterator {
if len ( p . keys ) == 0 {
return & APIKeyIterator { pool : p }
}
idx := atomic . AddUint32 ( & p . current , 1 ) - 1
return & APIKeyIterator {
pool : p ,
startIdx : idx ,
}
}
func ( it * APIKeyIterator ) Next ( ) ( string , bool ) {
length := uint32 ( len ( it . pool . keys ) )
if length == 0 || it . attempt >= length {
return "" , false
}
key := it . pool . keys [ ( it . startIdx + it . attempt ) % length ]
it . attempt ++
return key , true
}
2026-02-12 16:18:51 +00:00
type SearchProvider interface {
Search ( ctx context . Context , query string , count int ) ( string , error )
2026-02-04 11:06:13 +00:00
}
2026-02-12 16:18:51 +00:00
type BraveSearchProvider struct {
2026-03-10 08:34:11 +00:00
keyPool * APIKeyPool
proxy string
client * http . Client
2026-02-04 11:06:13 +00:00
}
2026-02-12 16:18:51 +00:00
func ( p * BraveSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
2026-02-04 11:06:13 +00:00
searchURL := fmt . Sprintf ( "https://api.search.brave.com/res/v1/web/search?q=%s&count=%d" ,
url . QueryEscape ( query ) , count )
2026-03-10 08:34:11 +00:00
var lastErr error
iter := p . keyPool . NewIterator ( )
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
for {
apiKey , ok := iter . Next ( )
if ! ok {
break
}
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
req , err := http . NewRequestWithContext ( ctx , "GET" , searchURL , nil )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
req . Header . Set ( "Accept" , "application/json" )
req . Header . Set ( "X-Subscription-Token" , apiKey )
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
resp , err := p . client . Do ( req )
if err != nil {
lastErr = fmt . Errorf ( "request failed: %w" , err )
continue
}
2026-03-03 09:50:29 +00:00
2026-03-10 08:34:11 +00:00
body , err := io . ReadAll ( resp . Body )
resp . Body . Close ( )
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
if err != nil {
lastErr = fmt . Errorf ( "failed to read response: %w" , err )
continue
}
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
if resp . StatusCode != http . StatusOK {
lastErr = fmt . Errorf ( "API error (status %d): %s" , resp . StatusCode , string ( body ) )
if resp . StatusCode == http . StatusTooManyRequests ||
resp . StatusCode == http . StatusUnauthorized ||
resp . StatusCode == http . StatusForbidden ||
resp . StatusCode >= 500 {
continue
}
return "" , lastErr
}
2026-02-04 11:06:13 +00:00
2026-03-10 08:34:11 +00:00
var searchResp struct {
Web struct {
Results [ ] struct {
Title string ` json:"title" `
URL string ` json:"url" `
Description string ` json:"description" `
} ` json:"results" `
} ` json:"web" `
}
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
// Log error body for debugging
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
results := searchResp . Web . Results
if len ( results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
2026-02-04 11:06:13 +00:00
}
2026-03-10 08:34:11 +00:00
var lines [ ] string
lines = append ( lines , fmt . Sprintf ( "Results for: %s" , query ) )
for i , item := range results {
if i >= count {
break
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , item . Title , item . URL ) )
if item . Description != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , item . Description ) )
}
2026-02-04 11:06:13 +00:00
}
2026-03-10 08:34:11 +00:00
return strings . Join ( lines , "\n" ) , nil
2026-02-04 11:06:13 +00:00
}
2026-03-10 08:34:11 +00:00
return "" , fmt . Errorf ( "all api keys failed, last error: %w" , lastErr )
2026-02-04 11:06:13 +00:00
}
2026-02-22 16:30:14 +00:00
type TavilySearchProvider struct {
2026-03-10 08:34:11 +00:00
keyPool * APIKeyPool
2026-02-22 16:30:14 +00:00
baseURL string
2026-02-23 08:23:10 +00:00
proxy string
2026-03-01 05:55:46 +00:00
client * http . Client
2026-02-22 16:30:14 +00:00
}
func ( p * TavilySearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := p . baseURL
if searchURL == "" {
searchURL = "https://api.tavily.com/search"
}
2026-03-10 08:34:11 +00:00
var lastErr error
iter := p . keyPool . NewIterator ( )
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
for {
apiKey , ok := iter . Next ( )
if ! ok {
break
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
payload := map [ string ] any {
"api_key" : apiKey ,
"query" : query ,
"search_depth" : "advanced" ,
"include_answer" : false ,
"include_images" : false ,
"include_raw_content" : false ,
"max_results" : count ,
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
bodyBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal payload: %w" , err )
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , bytes . NewBuffer ( bodyBytes ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "User-Agent" , userAgent )
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
resp , err := p . client . Do ( req )
if err != nil {
lastErr = fmt . Errorf ( "request failed: %w" , err )
continue
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
body , err := io . ReadAll ( resp . Body )
resp . Body . Close ( )
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
if err != nil {
lastErr = fmt . Errorf ( "failed to read response: %w" , err )
continue
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
if resp . StatusCode != http . StatusOK {
lastErr = fmt . Errorf ( "tavily api error (status %d): %s" , resp . StatusCode , string ( body ) )
if resp . StatusCode == http . StatusTooManyRequests ||
resp . StatusCode == http . StatusUnauthorized ||
resp . StatusCode == http . StatusForbidden ||
resp . StatusCode >= 500 {
continue
}
return "" , lastErr
}
2026-02-22 16:30:14 +00:00
2026-03-10 08:34:11 +00:00
var searchResp struct {
Results [ ] struct {
Title string ` json:"title" `
URL string ` json:"url" `
Content string ` json:"content" `
} ` json:"results" `
2026-02-22 16:30:14 +00:00
}
2026-03-10 08:34:11 +00:00
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
results := searchResp . Results
if len ( results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
2026-02-22 16:30:14 +00:00
}
2026-03-10 08:34:11 +00:00
var lines [ ] string
lines = append ( lines , fmt . Sprintf ( "Results for: %s (via Tavily)" , query ) )
for i , item := range results {
if i >= count {
break
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , item . Title , item . URL ) )
if item . Content != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , item . Content ) )
}
}
return strings . Join ( lines , "\n" ) , nil
2026-02-22 16:30:14 +00:00
}
2026-03-10 08:34:11 +00:00
return "" , fmt . Errorf ( "all api keys failed, last error: %w" , lastErr )
2026-02-22 16:30:14 +00:00
}
2026-02-24 09:16:16 +00:00
type DuckDuckGoSearchProvider struct {
2026-03-01 05:55:46 +00:00
proxy string
client * http . Client
2026-02-24 09:16:16 +00:00
}
2026-02-12 16:18:51 +00:00
func ( p * DuckDuckGoSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := fmt . Sprintf ( "https://html.duckduckgo.com/html/?q=%s" , url . QueryEscape ( query ) )
req , err := http . NewRequestWithContext ( ctx , "GET" , searchURL , nil )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
req . Header . Set ( "User-Agent" , userAgent )
2026-03-01 05:55:46 +00:00
resp , err := p . client . Do ( req )
2026-02-12 16:18:51 +00:00
if err != nil {
return "" , fmt . Errorf ( "request failed: %w" , err )
}
defer resp . Body . Close ( )
body , err := io . ReadAll ( resp . Body )
if err != nil {
return "" , fmt . Errorf ( "failed to read response: %w" , err )
}
return p . extractResults ( string ( body ) , count , query )
}
func ( p * DuckDuckGoSearchProvider ) extractResults ( html string , count int , query string ) ( string , error ) {
// Simple regex based extraction for DDG HTML
// Strategy: Find all result containers or key anchors directly
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// Try finding the result links directly first, as they are the most critical
// Pattern: <a class="result__a" href="...">Title</a>
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
2026-02-26 09:44:03 +00:00
matches := reDDGLink . FindAllStringSubmatch ( html , count + 5 )
2026-02-12 16:18:51 +00:00
if len ( matches ) == 0 {
return fmt . Sprintf ( "No results found or extraction failed. Query: %s" , query ) , nil
}
var lines [ ] string
lines = append ( lines , fmt . Sprintf ( "Results for: %s (via DuckDuckGo)" , query ) )
// Pre-compile snippet regex to run inside the loop
// We'll search for snippets relative to the link position or just globally if needed
// But simple global search for snippets might mismatch order.
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
// Or better: Let's assume the snippet follows the link in the HTML
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// A better regex approach: iterate through text and find matches in order
// But for now, let's grab all snippets too
2026-02-26 09:44:03 +00:00
snippetMatches := reDDGSnippet . FindAllStringSubmatch ( html , count + 5 )
2026-02-12 16:18:51 +00:00
maxItems := min ( len ( matches ) , count )
2026-02-14 01:47:55 +00:00
2026-02-27 08:35:07 +00:00
for i := range maxItems {
2026-02-12 16:18:51 +00:00
urlStr := matches [ i ] [ 1 ]
title := stripTags ( matches [ i ] [ 2 ] )
title = strings . TrimSpace ( title )
// URL decoding if needed
if strings . Contains ( urlStr , "uddg=" ) {
if u , err := url . QueryUnescape ( urlStr ) ; err == nil {
2026-02-27 08:35:07 +00:00
_ , after , ok := strings . Cut ( u , "uddg=" )
if ok {
urlStr = after
2026-02-12 16:18:51 +00:00
}
}
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , title , urlStr ) )
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// Attempt to attach snippet if available and index aligns
if i < len ( snippetMatches ) {
snippet := stripTags ( snippetMatches [ i ] [ 1 ] )
snippet = strings . TrimSpace ( snippet )
if snippet != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , snippet ) )
}
}
}
return strings . Join ( lines , "\n" ) , nil
}
func stripTags ( content string ) string {
2026-02-26 09:44:03 +00:00
return reTags . ReplaceAllString ( content , "" )
2026-02-12 16:18:51 +00:00
}
2026-02-17 13:02:56 +00:00
type PerplexitySearchProvider struct {
2026-03-10 08:34:11 +00:00
keyPool * APIKeyPool
proxy string
client * http . Client
2026-02-17 13:02:56 +00:00
}
func ( p * PerplexitySearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := "https://api.perplexity.ai/chat/completions"
2026-03-10 08:34:11 +00:00
var lastErr error
iter := p . keyPool . NewIterator ( )
for {
apiKey , ok := iter . Next ( )
if ! ok {
break
}
payload := map [ string ] any {
"model" : "sonar" ,
"messages" : [ ] map [ string ] string {
{
"role" : "system" ,
"content" : "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary." ,
} ,
{
"role" : "user" ,
"content" : fmt . Sprintf ( "Search for: %s. Provide up to %d relevant results." , query , count ) ,
} ,
2026-02-18 19:48:23 +00:00
} ,
2026-03-10 08:34:11 +00:00
"max_tokens" : 1000 ,
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
payloadBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal request: %w" , err )
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , strings . NewReader ( string ( payloadBytes ) ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "Authorization" , "Bearer " + apiKey )
req . Header . Set ( "User-Agent" , userAgent )
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
resp , err := p . client . Do ( req )
if err != nil {
lastErr = fmt . Errorf ( "request failed: %w" , err )
continue
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
body , err := io . ReadAll ( resp . Body )
resp . Body . Close ( )
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
if err != nil {
lastErr = fmt . Errorf ( "failed to read response: %w" , err )
continue
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
if resp . StatusCode != http . StatusOK {
lastErr = fmt . Errorf ( "Perplexity API error: %s" , string ( body ) )
if resp . StatusCode == http . StatusTooManyRequests ||
resp . StatusCode == http . StatusUnauthorized ||
resp . StatusCode == http . StatusForbidden ||
resp . StatusCode >= 500 {
continue
}
return "" , lastErr
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
var searchResp struct {
Choices [ ] struct {
Message struct {
Content string ` json:"content" `
} ` json:"message" `
} ` json:"choices" `
}
2026-02-17 13:02:56 +00:00
2026-03-10 08:34:11 +00:00
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
if len ( searchResp . Choices ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
}
return fmt . Sprintf ( "Results for: %s (via Perplexity)\n%s" , query , searchResp . Choices [ 0 ] . Message . Content ) , nil
2026-02-17 13:02:56 +00:00
}
2026-03-10 08:34:11 +00:00
return "" , fmt . Errorf ( "all api keys failed, last error: %w" , lastErr )
2026-02-17 13:02:56 +00:00
}
2026-02-20 11:02:00 +00:00
type SearXNGSearchProvider struct {
baseURL string
}
func ( p * SearXNGSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := fmt . Sprintf ( "%s/search?q=%s&format=json&categories=general" ,
strings . TrimSuffix ( p . baseURL , "/" ) ,
url . QueryEscape ( query ) )
req , err := http . NewRequestWithContext ( ctx , "GET" , searchURL , nil )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
client := & http . Client { Timeout : 10 * time . Second }
resp , err := client . Do ( req )
if err != nil {
return "" , fmt . Errorf ( "request failed: %w" , err )
}
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "SearXNG returned status %d" , resp . StatusCode )
}
var result struct {
Results [ ] struct {
Title string ` json:"title" `
URL string ` json:"url" `
Content string ` json:"content" `
Engine string ` json:"engine" `
Score float64 ` json:"score" `
} ` json:"results" `
}
if err := json . NewDecoder ( resp . Body ) . Decode ( & result ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
if len ( result . Results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
}
// Limit results to requested count
if len ( result . Results ) > count {
result . Results = result . Results [ : count ]
}
// Format results in standard PicoClaw format
var b strings . Builder
b . WriteString ( fmt . Sprintf ( "Results for: %s (via SearXNG)\n" , query ) )
for i , r := range result . Results {
b . WriteString ( fmt . Sprintf ( "%d. %s\n" , i + 1 , r . Title ) )
b . WriteString ( fmt . Sprintf ( " %s\n" , r . URL ) )
if r . Content != "" {
b . WriteString ( fmt . Sprintf ( " %s\n" , r . Content ) )
}
}
return b . String ( ) , nil
}
2026-03-04 06:58:12 +00:00
type GLMSearchProvider struct {
apiKey string
baseURL string
searchEngine string
proxy string
client * http . Client
}
func ( p * GLMSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := p . baseURL
if searchURL == "" {
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
}
payload := map [ string ] any {
"search_query" : query ,
"search_engine" : p . searchEngine ,
"search_intent" : false ,
"count" : count ,
"content_size" : "medium" ,
}
bodyBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal payload: %w" , err )
}
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , bytes . NewReader ( bodyBytes ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "Authorization" , "Bearer " + p . apiKey )
resp , err := p . client . Do ( req )
if err != nil {
return "" , fmt . Errorf ( "request failed: %w" , err )
}
defer resp . Body . Close ( )
body , err := io . ReadAll ( io . LimitReader ( resp . Body , 1 << 20 ) )
if err != nil {
return "" , fmt . Errorf ( "failed to read response: %w" , err )
}
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "GLM Search API error (status %d): %s" , resp . StatusCode , string ( body ) )
}
var searchResp struct {
SearchResult [ ] struct {
Title string ` json:"title" `
Content string ` json:"content" `
Link string ` json:"link" `
} ` json:"search_result" `
}
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
results := searchResp . SearchResult
if len ( results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
}
var lines [ ] string
lines = append ( lines , fmt . Sprintf ( "Results for: %s (via GLM Search)" , query ) )
for i , item := range results {
if i >= count {
break
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , item . Title , item . Link ) )
if item . Content != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , item . Content ) )
}
}
return strings . Join ( lines , "\n" ) , nil
}
2026-02-12 16:18:51 +00:00
type WebSearchTool struct {
provider SearchProvider
maxResults int
}
2026-02-13 09:12:55 +00:00
type WebSearchToolOptions struct {
2026-03-10 08:34:11 +00:00
BraveAPIKeys [ ] string
2026-02-13 09:12:55 +00:00
BraveMaxResults int
BraveEnabled bool
2026-03-10 08:34:11 +00:00
TavilyAPIKeys [ ] string
2026-02-22 16:30:14 +00:00
TavilyBaseURL string
TavilyMaxResults int
TavilyEnabled bool
2026-02-13 09:12:55 +00:00
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
2026-03-10 08:34:11 +00:00
PerplexityAPIKeys [ ] string
2026-02-17 13:02:56 +00:00
PerplexityMaxResults int
PerplexityEnabled bool
2026-02-20 11:02:00 +00:00
SearXNGBaseURL string
SearXNGMaxResults int
SearXNGEnabled bool
2026-03-04 06:58:12 +00:00
GLMSearchAPIKey string
GLMSearchBaseURL string
GLMSearchEngine string
GLMSearchMaxResults int
GLMSearchEnabled bool
2026-02-24 09:16:16 +00:00
Proxy string
2026-02-13 09:12:55 +00:00
}
2026-02-12 16:18:51 +00:00
2026-03-01 05:55:46 +00:00
func NewWebSearchTool ( opts WebSearchToolOptions ) ( * WebSearchTool , error ) {
2026-02-12 16:18:51 +00:00
var provider SearchProvider
2026-02-13 09:12:55 +00:00
maxResults := 5
2026-03-04 20:42:03 +00:00
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
2026-03-10 08:34:11 +00:00
if opts . PerplexityEnabled && len ( opts . PerplexityAPIKeys ) > 0 {
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( opts . Proxy , perplexityTimeout )
2026-03-01 05:55:46 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Perplexity: %w" , err )
}
2026-03-10 08:34:11 +00:00
provider = & PerplexitySearchProvider {
keyPool : NewAPIKeyPool ( opts . PerplexityAPIKeys ) ,
proxy : opts . Proxy ,
client : client ,
}
2026-02-17 13:02:56 +00:00
if opts . PerplexityMaxResults > 0 {
maxResults = opts . PerplexityMaxResults
}
2026-03-10 08:34:11 +00:00
} else if opts . BraveEnabled && len ( opts . BraveAPIKeys ) > 0 {
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( opts . Proxy , searchTimeout )
2026-03-01 05:55:46 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Brave: %w" , err )
}
2026-03-10 08:34:11 +00:00
provider = & BraveSearchProvider { keyPool : NewAPIKeyPool ( opts . BraveAPIKeys ) , proxy : opts . Proxy , client : client }
2026-02-13 09:12:55 +00:00
if opts . BraveMaxResults > 0 {
maxResults = opts . BraveMaxResults
}
2026-02-20 11:02:00 +00:00
} else if opts . SearXNGEnabled && opts . SearXNGBaseURL != "" {
provider = & SearXNGSearchProvider { baseURL : opts . SearXNGBaseURL }
if opts . SearXNGMaxResults > 0 {
maxResults = opts . SearXNGMaxResults
}
2026-03-10 08:34:11 +00:00
} else if opts . TavilyEnabled && len ( opts . TavilyAPIKeys ) > 0 {
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( opts . Proxy , searchTimeout )
2026-03-01 05:55:46 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Tavily: %w" , err )
}
2026-02-22 16:30:14 +00:00
provider = & TavilySearchProvider {
2026-03-10 08:34:11 +00:00
keyPool : NewAPIKeyPool ( opts . TavilyAPIKeys ) ,
2026-02-22 16:30:14 +00:00
baseURL : opts . TavilyBaseURL ,
2026-02-23 08:23:10 +00:00
proxy : opts . Proxy ,
2026-03-01 05:55:46 +00:00
client : client ,
2026-02-22 16:30:14 +00:00
}
if opts . TavilyMaxResults > 0 {
maxResults = opts . TavilyMaxResults
}
2026-02-13 09:12:55 +00:00
} else if opts . DuckDuckGoEnabled {
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( opts . Proxy , searchTimeout )
2026-03-01 05:55:46 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for DuckDuckGo: %w" , err )
}
provider = & DuckDuckGoSearchProvider { proxy : opts . Proxy , client : client }
2026-02-13 09:12:55 +00:00
if opts . DuckDuckGoMaxResults > 0 {
maxResults = opts . DuckDuckGoMaxResults
}
2026-03-04 06:58:12 +00:00
} else if opts . GLMSearchEnabled && opts . GLMSearchAPIKey != "" {
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( opts . Proxy , searchTimeout )
2026-03-04 06:58:12 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for GLM Search: %w" , err )
}
searchEngine := opts . GLMSearchEngine
if searchEngine == "" {
searchEngine = "search_std"
}
provider = & GLMSearchProvider {
apiKey : opts . GLMSearchAPIKey ,
baseURL : opts . GLMSearchBaseURL ,
searchEngine : searchEngine ,
proxy : opts . Proxy ,
client : client ,
}
if opts . GLMSearchMaxResults > 0 {
maxResults = opts . GLMSearchMaxResults
}
2026-02-13 09:12:55 +00:00
} else {
2026-03-01 05:55:46 +00:00
return nil , nil
2026-02-12 16:18:51 +00:00
}
return & WebSearchTool {
provider : provider ,
maxResults : maxResults ,
2026-03-01 05:55:46 +00:00
} , nil
2026-02-12 16:18:51 +00:00
}
func ( t * WebSearchTool ) Name ( ) string {
return "web_search"
}
func ( t * WebSearchTool ) Description ( ) string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
}
2026-02-18 19:48:23 +00:00
func ( t * WebSearchTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"query" : map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "string" ,
"description" : "Search query" ,
} ,
2026-02-18 19:48:23 +00:00
"count" : map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "integer" ,
"description" : "Number of results (1-10)" ,
"minimum" : 1.0 ,
"maximum" : 10.0 ,
} ,
} ,
"required" : [ ] string { "query" } ,
}
}
2026-02-18 19:48:23 +00:00
func ( t * WebSearchTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-12 16:18:51 +00:00
query , ok := args [ "query" ] . ( string )
if ! ok {
2026-02-13 10:06:43 +00:00
return ErrorResult ( "query is required" )
2026-02-12 16:18:51 +00:00
}
count := t . maxResults
if c , ok := args [ "count" ] . ( float64 ) ; ok {
if int ( c ) > 0 && int ( c ) <= 10 {
count = int ( c )
}
}
2026-02-13 10:11:37 +00:00
result , err := t . provider . Search ( ctx , query , count )
if err != nil {
return ErrorResult ( fmt . Sprintf ( "search failed: %v" , err ) )
}
return & ToolResult {
ForLLM : result ,
ForUser : result ,
}
2026-02-12 16:18:51 +00:00
}
2026-02-04 11:06:13 +00:00
type WebFetchTool struct {
2026-02-28 12:34:33 +00:00
maxChars int
proxy string
2026-03-01 22:44:21 +00:00
client * http . Client
2026-03-15 21:12:03 +00:00
format string
2026-02-28 12:34:33 +00:00
fetchLimitBytes int64
2026-03-17 15:22:05 +00:00
whitelist * privateHostWhitelist
}
type privateHostWhitelist struct {
exact map [ string ] struct { }
cidrs [ ] * net . IPNet
2026-02-04 11:06:13 +00:00
}
2026-03-15 21:12:03 +00:00
func NewWebFetchTool ( maxChars int , format string , fetchLimitBytes int64 ) ( * WebFetchTool , error ) {
2026-03-01 22:44:21 +00:00
// createHTTPClient cannot fail with an empty proxy string.
2026-03-17 16:14:23 +00:00
return NewWebFetchToolWithConfig ( maxChars , "" , format , fetchLimitBytes , nil )
2026-02-04 11:06:13 +00:00
}
2026-03-11 11:22:20 +00:00
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
var allowPrivateWebFetchHosts atomic . Bool
2026-03-17 16:14:23 +00:00
func NewWebFetchToolWithProxy (
maxChars int ,
proxy string ,
format string ,
fetchLimitBytes int64 ,
privateHostWhitelist [ ] string ,
) ( * WebFetchTool , error ) {
return NewWebFetchToolWithConfig ( maxChars , proxy , format , fetchLimitBytes , privateHostWhitelist )
2026-03-17 15:22:05 +00:00
}
func NewWebFetchToolWithConfig (
maxChars int ,
proxy string ,
2026-03-17 16:14:23 +00:00
format string ,
2026-03-17 15:22:05 +00:00
fetchLimitBytes int64 ,
privateHostWhitelist [ ] string ,
) ( * WebFetchTool , error ) {
2026-02-24 09:16:16 +00:00
if maxChars <= 0 {
2026-03-01 05:55:46 +00:00
maxChars = defaultMaxChars
2026-02-24 09:16:16 +00:00
}
2026-03-17 15:22:05 +00:00
whitelist , err := newPrivateHostWhitelist ( privateHostWhitelist )
if err != nil {
return nil , fmt . Errorf ( "failed to parse web fetch private host whitelist: %w" , err )
}
2026-03-13 06:04:02 +00:00
client , err := utils . CreateHTTPClient ( proxy , fetchTimeout )
2026-03-01 05:55:46 +00:00
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for web fetch: %w" , err )
2026-02-24 09:16:16 +00:00
}
2026-03-11 11:22:20 +00:00
if transport , ok := client . Transport . ( * http . Transport ) ; ok {
dialer := & net . Dialer {
Timeout : 15 * time . Second ,
KeepAlive : 30 * time . Second ,
}
2026-03-17 15:22:05 +00:00
transport . DialContext = newSafeDialContext ( dialer , whitelist )
2026-03-11 11:22:20 +00:00
}
2026-03-01 05:55:46 +00:00
client . CheckRedirect = func ( req * http . Request , via [ ] * http . Request ) error {
if len ( via ) >= maxRedirects {
return fmt . Errorf ( "stopped after %d redirects" , maxRedirects )
}
2026-03-17 15:22:05 +00:00
if isObviousPrivateHost ( req . URL . Hostname ( ) , whitelist ) {
2026-03-11 11:22:20 +00:00
return fmt . Errorf ( "redirect target is private or local network host" )
}
2026-03-01 05:55:46 +00:00
return nil
2026-02-24 09:16:16 +00:00
}
2026-02-28 12:34:33 +00:00
if fetchLimitBytes <= 0 {
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
}
2026-02-24 09:16:16 +00:00
return & WebFetchTool {
2026-02-28 12:34:33 +00:00
maxChars : maxChars ,
proxy : proxy ,
2026-03-01 22:44:21 +00:00
client : client ,
2026-03-15 21:12:03 +00:00
format : format ,
2026-02-28 12:34:33 +00:00
fetchLimitBytes : fetchLimitBytes ,
2026-03-17 15:22:05 +00:00
whitelist : whitelist ,
2026-03-01 22:44:21 +00:00
} , nil
2026-02-24 09:16:16 +00:00
}
2026-02-04 11:06:13 +00:00
func ( t * WebFetchTool ) Name ( ) string {
return "web_fetch"
}
func ( t * WebFetchTool ) Description ( ) string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
}
2026-02-18 19:48:23 +00:00
func ( t * WebFetchTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"url" : map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "string" ,
"description" : "URL to fetch" ,
} ,
2026-02-18 19:48:23 +00:00
"maxChars" : map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "integer" ,
"description" : "Maximum characters to extract" ,
"minimum" : 100.0 ,
} ,
} ,
"required" : [ ] string { "url" } ,
}
}
2026-02-18 19:48:23 +00:00
func ( t * WebFetchTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-04 11:06:13 +00:00
urlStr , ok := args [ "url" ] . ( string )
if ! ok {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "url is required" )
2026-02-04 11:06:13 +00:00
}
parsedURL , err := url . Parse ( urlStr )
if err != nil {
2026-02-12 11:28:56 +00:00
return ErrorResult ( fmt . Sprintf ( "invalid URL: %v" , err ) )
2026-02-04 11:06:13 +00:00
}
if parsedURL . Scheme != "http" && parsedURL . Scheme != "https" {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "only http/https URLs are allowed" )
2026-02-04 11:06:13 +00:00
}
if parsedURL . Host == "" {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "missing domain in URL" )
2026-02-04 11:06:13 +00:00
}
2026-03-11 11:22:20 +00:00
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
// The real SSRF guard is newSafeDialContext at connect time.
hostname := parsedURL . Hostname ( )
2026-03-17 15:22:05 +00:00
if isObviousPrivateHost ( hostname , t . whitelist ) {
2026-03-11 11:22:20 +00:00
return ErrorResult ( "fetching private or local network hosts is not allowed" )
}
2026-02-04 11:06:13 +00:00
maxChars := t . maxChars
if mc , ok := args [ "maxChars" ] . ( float64 ) ; ok {
if int ( mc ) > 100 {
maxChars = int ( mc )
}
}
2026-03-19 09:01:45 +00:00
doFetch := func ( ua string ) ( * http . Response , [ ] byte , error ) {
req , reqErr := http . NewRequestWithContext ( ctx , "GET" , urlStr , nil )
if reqErr != nil {
return nil , nil , fmt . Errorf ( "failed to create request: %w" , reqErr )
}
req . Header . Set ( "User-Agent" , ua )
resp , doErr := t . client . Do ( req )
if doErr != nil {
return nil , nil , fmt . Errorf ( "request failed: %w" , doErr )
}
resp . Body = http . MaxBytesReader ( nil , resp . Body , t . fetchLimitBytes )
2026-02-04 11:06:13 +00:00
2026-03-19 09:01:45 +00:00
b , readErr := io . ReadAll ( resp . Body )
return resp , b , readErr
2026-02-24 09:16:16 +00:00
}
2026-03-19 09:01:45 +00:00
resp , body , err := doFetch ( userAgent )
if resp != nil && resp . Body != nil {
defer resp . Body . Close ( )
}
2026-02-04 11:06:13 +00:00
if err != nil {
2026-02-27 17:56:02 +00:00
var maxBytesErr * http . MaxBytesError
if errors . As ( err , & maxBytesErr ) {
2026-02-28 12:34:33 +00:00
return ErrorResult ( fmt . Sprintf ( "failed to read response: size exceeded %d bytes limit" , t . fetchLimitBytes ) )
2026-02-27 17:56:02 +00:00
}
2026-03-19 09:01:45 +00:00
return ErrorResult ( err . Error ( ) )
}
// Cloudflare (and similar WAFs) signal bot challenges with 403 + cf-mitigated: challenge.
// Retry once with an honest User-Agent that identifies picoclaw, which some
// operators explicitly allow-list for AI assistants.
if resp . StatusCode == http . StatusForbidden && resp . Header . Get ( "Cf-Mitigated" ) == "challenge" {
logger . DebugCF ( "tool" , "Cloudflare challenge detected, retrying with honest User-Agent" ,
map [ string ] any { "url" : urlStr } )
honestUA := fmt . Sprintf ( userAgentHonest , config . Version )
resp2 , body2 , err2 := doFetch ( honestUA )
if resp2 != nil && resp2 . Body != nil {
defer resp2 . Body . Close ( )
}
if err2 == nil {
resp , body = resp2 , body2
} else {
var maxBytesErr * http . MaxBytesError
if errors . As ( err2 , & maxBytesErr ) {
return ErrorResult (
fmt . Sprintf ( "failed to read response: size exceeded %d bytes limit" , t . fetchLimitBytes ) ,
)
}
return ErrorResult ( err2 . Error ( ) )
}
2026-02-04 11:06:13 +00:00
}
2026-03-15 21:12:03 +00:00
bodyStr := string ( body )
2026-02-04 11:06:13 +00:00
contentType := resp . Header . Get ( "Content-Type" )
2026-03-17 16:14:23 +00:00
mediaType , params , err := mime . ParseMediaType ( contentType )
if err != nil {
// The most common error here is "mime: no media type" if the header is empty.
logger . WarnCF ( "tool" , "Failed to parse Content-Type" , map [ string ] any {
"raw_header" : contentType ,
"error" : err . Error ( ) ,
} )
// security fallback
mediaType = "application/octet-stream"
}
charset , hasCharset := params [ "charset" ]
if hasCharset {
// If the charset is not utf-8, we might have to convert the bodyStr
// before passing it to the HTML/Markdown parser
if strings . ToLower ( charset ) != "utf-8" {
logger . WarnCF ( "tool" , "Note: the content is not in UTF-8" , map [ string ] any { "charset" : charset } )
}
}
2026-03-15 21:12:03 +00:00
2026-02-04 11:06:13 +00:00
var text , extractor string
2026-03-15 21:12:03 +00:00
switch {
case mediaType == "application/json" :
2026-02-18 19:48:23 +00:00
var jsonData any
2026-03-15 21:12:03 +00:00
if err := json . Unmarshal ( body , & jsonData ) ; err != nil {
text = bodyStr
2026-02-04 11:06:13 +00:00
extractor = "raw"
2026-03-15 21:12:03 +00:00
break
2026-02-04 11:06:13 +00:00
}
2026-03-15 21:12:03 +00:00
formatted , err := json . MarshalIndent ( jsonData , "" , " " )
if err != nil {
text = bodyStr
extractor = "raw"
break
}
text = string ( formatted )
extractor = "json"
case mediaType == "text/html" || looksLikeHTML ( bodyStr ) :
switch strings . ToLower ( t . format ) {
case "markdown" :
var err error
text , err = utils . HtmlToMarkdown ( bodyStr )
if err != nil {
return ErrorResult ( fmt . Sprintf ( "failed to HTML to markdown: %v" , err ) )
}
extractor = "markdown"
default :
text = t . extractText ( bodyStr )
extractor = "text"
}
default :
text = bodyStr
2026-02-04 11:06:13 +00:00
extractor = "raw"
}
truncated := len ( text ) > maxChars
if truncated {
2026-03-19 09:01:45 +00:00
text = text [ : maxChars ] + "\n[Content truncated due to size limit]"
2026-02-04 11:06:13 +00:00
}
2026-02-18 19:48:23 +00:00
result := map [ string ] any {
2026-02-04 11:06:13 +00:00
"url" : urlStr ,
"status" : resp . StatusCode ,
"extractor" : extractor ,
"truncated" : truncated ,
"length" : len ( text ) ,
"text" : text ,
}
resultJSON , _ := json . MarshalIndent ( result , "" , " " )
2026-02-12 11:28:56 +00:00
return & ToolResult {
2026-03-01 20:48:11 +00:00
ForLLM : string ( resultJSON ) ,
ForUser : fmt . Sprintf (
2026-02-18 19:48:23 +00:00
"Fetched %d bytes from %s (extractor: %s, truncated: %v)" ,
len ( text ) ,
urlStr ,
extractor ,
truncated ,
) ,
2026-02-12 11:28:56 +00:00
}
2026-02-04 11:06:13 +00:00
}
2026-03-15 21:12:03 +00:00
func looksLikeHTML ( body string ) bool {
if body == "" {
return false
}
lower := strings . ToLower ( body )
return strings . HasPrefix ( body , "<!doctype" ) ||
strings . HasPrefix ( lower , "<html" )
}
2026-02-04 11:06:13 +00:00
func ( t * WebFetchTool ) extractText ( htmlContent string ) string {
2026-02-26 09:44:03 +00:00
result := reScript . ReplaceAllLiteralString ( htmlContent , "" )
result = reStyle . ReplaceAllLiteralString ( result , "" )
result = reTags . ReplaceAllLiteralString ( result , "" )
2026-02-04 11:06:13 +00:00
result = strings . TrimSpace ( result )
2026-02-26 09:44:03 +00:00
result = reWhitespace . ReplaceAllString ( result , " " )
result = reBlankLines . ReplaceAllString ( result , "\n\n" )
2026-02-04 11:06:13 +00:00
lines := strings . Split ( result , "\n" )
var cleanLines [ ] string
for _ , line := range lines {
line = strings . TrimSpace ( line )
if line != "" {
cleanLines = append ( cleanLines , line )
}
}
return strings . Join ( cleanLines , "\n" )
}
2026-03-11 11:22:20 +00:00
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
2026-03-17 15:22:05 +00:00
func newSafeDialContext (
dialer * net . Dialer ,
whitelist * privateHostWhitelist ,
) func ( context . Context , string , string ) ( net . Conn , error ) {
2026-03-11 11:22:20 +00:00
return func ( ctx context . Context , network , address string ) ( net . Conn , error ) {
if allowPrivateWebFetchHosts . Load ( ) {
return dialer . DialContext ( ctx , network , address )
}
host , port , err := net . SplitHostPort ( address )
if err != nil {
return nil , fmt . Errorf ( "invalid target address %q: %w" , address , err )
}
if host == "" {
return nil , fmt . Errorf ( "empty target host" )
}
if ip := net . ParseIP ( host ) ; ip != nil {
2026-03-17 15:22:05 +00:00
if shouldBlockPrivateIP ( ip , whitelist ) {
2026-03-11 11:22:20 +00:00
return nil , fmt . Errorf ( "blocked private or local target: %s" , host )
}
return dialer . DialContext ( ctx , network , net . JoinHostPort ( ip . String ( ) , port ) )
}
ipAddrs , err := net . DefaultResolver . LookupIPAddr ( ctx , host )
if err != nil {
return nil , fmt . Errorf ( "failed to resolve %s: %w" , host , err )
}
attempted := 0
var lastErr error
for _ , ipAddr := range ipAddrs {
2026-03-17 15:22:05 +00:00
if shouldBlockPrivateIP ( ipAddr . IP , whitelist ) {
2026-03-11 11:22:20 +00:00
continue
}
attempted ++
conn , err := dialer . DialContext ( ctx , network , net . JoinHostPort ( ipAddr . IP . String ( ) , port ) )
if err == nil {
return conn , nil
}
lastErr = err
}
if attempted == 0 {
2026-03-17 15:22:05 +00:00
return nil , fmt . Errorf ( "all resolved addresses for %s are private, restricted, or not whitelisted" , host )
2026-03-11 11:22:20 +00:00
}
if lastErr != nil {
return nil , fmt . Errorf ( "failed connecting to public addresses for %s: %w" , host , lastErr )
}
return nil , fmt . Errorf ( "failed connecting to public addresses for %s" , host )
}
}
2026-03-17 15:22:05 +00:00
func newPrivateHostWhitelist ( entries [ ] string ) ( * privateHostWhitelist , error ) {
if len ( entries ) == 0 {
return nil , nil
}
whitelist := & privateHostWhitelist {
exact : make ( map [ string ] struct { } ) ,
cidrs : make ( [ ] * net . IPNet , 0 , len ( entries ) ) ,
}
for _ , entry := range entries {
entry = strings . TrimSpace ( entry )
if entry == "" {
continue
}
if ip := net . ParseIP ( entry ) ; ip != nil {
whitelist . exact [ normalizeWhitelistIP ( ip ) . String ( ) ] = struct { } { }
continue
}
_ , network , err := net . ParseCIDR ( entry )
if err != nil {
return nil , fmt . Errorf ( "invalid entry %q: expected IP or CIDR" , entry )
}
whitelist . cidrs = append ( whitelist . cidrs , network )
}
if len ( whitelist . exact ) == 0 && len ( whitelist . cidrs ) == 0 {
return nil , nil
}
return whitelist , nil
}
func ( w * privateHostWhitelist ) Contains ( ip net . IP ) bool {
if w == nil || ip == nil {
return false
}
normalized := normalizeWhitelistIP ( ip )
if _ , ok := w . exact [ normalized . String ( ) ] ; ok {
return true
}
for _ , network := range w . cidrs {
if network . Contains ( normalized ) {
return true
}
}
return false
}
func normalizeWhitelistIP ( ip net . IP ) net . IP {
if ip == nil {
return nil
}
if ip4 := ip . To4 ( ) ; ip4 != nil {
return ip4
}
return ip
}
func shouldBlockPrivateIP ( ip net . IP , whitelist * privateHostWhitelist ) bool {
return isPrivateOrRestrictedIP ( ip ) && ! whitelist . Contains ( ip )
}
2026-03-11 11:22:20 +00:00
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
2026-03-17 15:22:05 +00:00
func isObviousPrivateHost ( host string , whitelist * privateHostWhitelist ) bool {
2026-03-11 11:22:20 +00:00
if allowPrivateWebFetchHosts . Load ( ) {
return false
}
h := strings . ToLower ( strings . TrimSpace ( host ) )
h = strings . TrimSuffix ( h , "." )
if h == "" {
return true
}
if h == "localhost" || strings . HasSuffix ( h , ".localhost" ) {
return true
}
if ip := net . ParseIP ( h ) ; ip != nil {
2026-03-17 15:22:05 +00:00
return shouldBlockPrivateIP ( ip , whitelist )
2026-03-11 11:22:20 +00:00
}
return false
}
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32).
func isPrivateOrRestrictedIP ( ip net . IP ) bool {
if ip == nil {
return true
}
if ip . IsLoopback ( ) || ip . IsLinkLocalUnicast ( ) || ip . IsLinkLocalMulticast ( ) ||
ip . IsMulticast ( ) || ip . IsUnspecified ( ) {
return true
}
if ip4 := ip . To4 ( ) ; ip4 != nil {
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
if ip4 [ 0 ] == 10 ||
ip4 [ 0 ] == 127 ||
ip4 [ 0 ] == 0 ||
( ip4 [ 0 ] == 172 && ip4 [ 1 ] >= 16 && ip4 [ 1 ] <= 31 ) ||
( ip4 [ 0 ] == 192 && ip4 [ 1 ] == 168 ) ||
( ip4 [ 0 ] == 169 && ip4 [ 1 ] == 254 ) ||
( ip4 [ 0 ] == 100 && ip4 [ 1 ] >= 64 && ip4 [ 1 ] <= 127 ) {
return true
}
return false
}
if len ( ip ) == net . IPv6len {
// IPv6 unique local addresses (fc00::/7)
if ( ip [ 0 ] & 0xfe ) == 0xfc {
return true
}
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
if ip [ 0 ] == 0x20 && ip [ 1 ] == 0x02 {
embedded := net . IPv4 ( ip [ 2 ] , ip [ 3 ] , ip [ 4 ] , ip [ 5 ] )
return isPrivateOrRestrictedIP ( embedded )
}
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
if ip [ 0 ] == 0x20 && ip [ 1 ] == 0x01 && ip [ 2 ] == 0x00 && ip [ 3 ] == 0x00 {
client := net . IPv4 ( ip [ 12 ] ^ 0xff , ip [ 13 ] ^ 0xff , ip [ 14 ] ^ 0xff , ip [ 15 ] ^ 0xff )
return isPrivateOrRestrictedIP ( client )
}
}
return false
}