package bot // The fetch capability: the one place a bot touches the public web, used by // task runs (task.go). Deliberately narrow — HTTPS GET only, against a // per-rule allowlist declared in the recipe (a task with no allowlist has no // network), with the same private-address blocking as the server's unfurler. // The sites fetched see the sidecar box's address; that widening is disclosed // in the design doc and in the consent card a builder shows before creating a // bot that uses this. import ( "errors" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "syscall" "time" ) // maxFetchBytes caps one fetched body. Feeds and article pages fit far below // this; anything bigger gets truncated, not rejected — a partial feed still // summarises. const maxFetchBytes = 512 * 1024 const fetchTimeout = 20 * time.Second // allowPrivateFetch lets tests fetch from loopback httptest servers. Never set // in production units. func allowPrivateFetch() bool { return os.Getenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH") == "1" } // checkFetchURL enforces scheme + allowlist. Allow entries are URL prefixes // ("https://www.rte.ie/feeds/"); a redirect target is re-checked with the same // rule, so a fetch can never wander off the list. The apex↔www dance counts // as the same site (feed discovery already treats it so): a homepage-fallback // entry like "https://irishtimes.com" must survive the site's own 301 to // www.irishtimes.com, or every fetch dies on its first redirect. func checkFetchURL(raw string, allow []string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("bad url: %w", err) } scheme := "https" if allowPrivateFetch() { scheme = "http" // loopback test servers are plain http } if u.Scheme != "https" && u.Scheme != scheme { return fmt.Errorf("only https urls may be fetched") } for _, candidate := range []string{raw, toggleWWW(u)} { if candidate == "" { continue } for _, a := range allow { if allowPrefixMatch(candidate, a) { return nil } } } return fmt.Errorf("url not in this bot's allowlist") } // allowPrefixMatch reports whether a URL falls under one allow entry. Not a // bare HasPrefix: a bare-host entry ("https://irishtimes.com") must not match // a LONGER host ("https://irishtimes.com.evil.com" — and the MODEL picks // fetch URLs, so the boundary is load-bearing). Unless the entry already ends // inside its path or query, the match must stop at a URL boundary character. func allowPrefixMatch(raw, entry string) bool { if !strings.HasPrefix(raw, entry) { return false } if len(raw) == len(entry) || strings.HasSuffix(entry, "/") || strings.Contains(entry, "?") { return true } switch raw[len(entry)] { case '/', '?', '#': return true } return false } // toggleWWW is u with its host's leading "www." flipped (added or removed) — // the same-site variant the allowlist tolerates. "" when there's no host. func toggleWWW(u *url.URL) string { if u.Host == "" { return "" } v := *u if strings.HasPrefix(v.Host, "www.") { v.Host = strings.TrimPrefix(v.Host, "www.") } else { v.Host = "www." + v.Host } return v.String() } // privateIP reports whether an address must not be dialled: loopback, // link-local, RFC1918/ULA — the standard SSRF surface. Checked in the dialer's // Control hook, i.e. against the address actually being connected to (after // DNS), so a hostname that resolves privately is caught too. func privateIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() } func fetchClient(allow []string) *http.Client { dialer := &net.Dialer{ Timeout: 10 * time.Second, Control: func(network, address string, _ syscall.RawConn) error { if allowPrivateFetch() { return nil } host, _, err := net.SplitHostPort(address) if err != nil { return err } if ip := net.ParseIP(host); ip == nil || privateIP(ip) { return errors.New("private address blocked") } return nil }, } return &http.Client{ Timeout: fetchTimeout, Transport: &http.Transport{DialContext: dialer.DialContext}, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("too many redirects") } // A redirect is a new URL: hold it to the same allowlist + scheme // rules as the original. return checkFetchURL(req.URL.String(), allow) }, } } // fetchURL GETs one allowlisted URL and returns its body as text (truncated at // maxFetchBytes). Errors are returned as values — the task loop feeds them // back to the model as tool_result content, so a dead feed degrades to an // honest "couldn't fetch" instead of a silent gap. func fetchURL(raw string, allow []string) (string, error) { if err := checkFetchURL(raw, allow); err != nil { return "", err } req, err := http.NewRequest(http.MethodGet, raw, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "lchat-bot/1.0 (+https://elevenmessenger.com)") req.Header.Set("Accept", "application/rss+xml, application/atom+xml, text/html, text/plain, */*") resp, err := fetchClient(allow).Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("fetch %s: status %d", raw, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes)) if err != nil { return "", err } return string(body), nil }