package websearch
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
// Fetch limits. A page is read to be quoted into a context window, so the
// ceiling is set by what is worth sending to the model rather than by what the
// machine could hold.
const (
// DefaultMaxBytes is how much of a page is read when nothing says otherwise.
DefaultMaxBytes = 512 << 10
// MaxBytes is the ceiling on asking for more.
MaxBytes = 2 << 20
// FetchTimeout is how long one page may take, start to finish.
FetchTimeout = 15 * time.Second
)
// Page is a fetched document, reduced to text.
type Page struct {
// URL is where the content finally came from, after redirects.
URL string
ContentType string
Text string
Bytes int
// Truncated reports that the read stopped at the byte ceiling, so the text
// ends in the middle of the document rather than at the end of it.
Truncated bool
}
// CheckURL parses and vets a URL before anything dials it.
//
// This is the path jail's twin, and it exists for the same reason: the argument
// is written by the model, not by the user. A fetch tool that will dial
// whatever it is handed is an SSRF hole pointed at everything the machine can
// reach but the internet cannot — 169.254.169.254 holds cloud credentials, and
// localhost holds whatever the user is developing. The check is on resolved
// addresses rather than on the hostname, because a name can point anywhere and
// an attacker who controls a DNS record controls what a name means.
func CheckURL(ctx context.Context, raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("not a URL: %v", err)
}
switch u.Scheme {
case "http", "https":
case "":
return nil, fmt.Errorf("%s has no scheme — give an absolute http or https URL", raw)
default:
return nil, fmt.Errorf("%s: only http and https can be fetched", u.Scheme)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("%s has no host", raw)
}
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("cannot resolve %s: %v", host, err)
}
for _, a := range addrs {
if err := checkAddr(a); err != nil {
return nil, fmt.Errorf("%s resolves to %s: %w", host, a, err)
}
}
return u, nil
}
// checkAddr is the rule itself: an address must be one the public internet
// could also route to. Everything else belongs to this machine or this network
// and is none of the model's business.
func checkAddr(a netip.Addr) error {
a = a.Unmap()
switch {
case a.IsLoopback():
return fmt.Errorf("a loopback address, which is this machine")
case a.IsPrivate():
return fmt.Errorf("a private address, which is this network")
case a.IsLinkLocalUnicast(), a.IsLinkLocalMulticast():
return fmt.Errorf("a link-local address (169.254.169.254 and friends hold cloud credentials)")
case a.IsUnspecified():
return fmt.Errorf("an unspecified address")
case a.IsMulticast(), a.IsInterfaceLocalMulticast():
return fmt.Errorf("a multicast address")
case isUniqueLocal(a):
return fmt.Errorf("a unique-local address, which is this network")
case !a.IsGlobalUnicast():
return fmt.Errorf("not a routable address")
}
return nil
}
// isUniqueLocal covers fc00::/7, IPv6's answer to 10.0.0.0/8. netip's IsPrivate
// already says yes to it, but the two are spelled out separately so that a
// change in either does not silently widen the other.
func isUniqueLocal(a netip.Addr) bool {
return a.Is6() && a.As16()[0]&0xfe == 0xfc
}
// SafeClient is an HTTP client that cannot be talked out of the jail.
//
// The check in CheckURL happens before the request; these two happen during it.
// CheckRedirect re-runs the whole check on every hop, because a public URL that
// 302s to http://127.0.0.1:8080 would otherwise walk straight through the front
// door. DialContext re-checks the address actually being connected to, which
// closes the window between resolving a name and dialling it — the same name
// can answer differently twice.
func SafeClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if ip, err := netip.ParseAddr(host); err == nil {
if err := checkAddr(ip); err != nil {
return nil, fmt.Errorf("refusing to connect to %s: %w", host, err)
}
}
return dialer.DialContext(ctx, network, addr)
},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: timeout,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if _, err := CheckURL(req.Context(), req.URL.String()); err != nil {
return fmt.Errorf("refusing the redirect to %s: %w", req.URL, err)
}
return nil
},
}
}
// Fetch retrieves one URL and returns it as text.
//
// Everything about it is bounded: the address it may dial, the time it may
// take, the bytes it may read, and the types it will accept. A tool the model
// drives has to be, because the model is not the one who pays for an
// unbounded read of something that turned out to be a disk image.
func Fetch(ctx context.Context, raw string, maxBytes int) (*Page, error) {
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
defer cancel()
u, err := CheckURL(ctx, raw)
if err != nil {
return nil, err
}
return fetchWith(ctx, SafeClient(FetchTimeout), u.String(), maxBytes)
}
// fetchWith is Fetch once the URL has been vetted and a client chosen. The
// split exists for the tests: an httptest server lives on loopback, which the
// jail refuses by design, so the reading and the refusing have to be provable
// separately.
func fetchWith(ctx context.Context, client *http.Client, raw string, maxBytes int) (*Page, error) {
if maxBytes <= 0 {
maxBytes = DefaultMaxBytes
}
if maxBytes > MaxBytes {
maxBytes = MaxBytes
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
// A plausible browser string, because a bare Go user-agent is blocked by
// enough documentation sites to make the tool look broken, and an honest
// suffix so an operator reading their logs knows what this is.
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; codey/1.0; +https://github.com/)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain,application/json;q=0.9,*/*;q=0.1")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching %s: %v", raw, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s returned %s", raw, resp.Status)
}
ctype := resp.Header.Get("Content-Type")
kind, ok := readableKind(ctype)
if !ok {
// Refused before the body is read: there is no point spending a
// megabyte of someone's connection on a PDF this cannot render.
return nil, fmt.Errorf("%s is %s, which cannot be read as text", raw, firstField(ctype))
}
// One byte past the ceiling, so hitting it is distinguishable from ending
// exactly on it.
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)+1))
if err != nil {
return nil, fmt.Errorf("reading %s: %v", raw, err)
}
truncated := len(body) > maxBytes
if truncated {
body = body[:maxBytes]
}
text := string(body)
if kind == kindHTML {
text, err = HTMLToText(strings.NewReader(text))
if err != nil {
return nil, fmt.Errorf("reading %s: %v", raw, err)
}
}
return &Page{
URL: resp.Request.URL.String(),
ContentType: firstField(ctype),
Text: strings.TrimSpace(text),
Bytes: len(body),
Truncated: truncated,
}, nil
}
type contentKind int
const (
kindHTML contentKind = iota
kindPlain
)
// readableKind decides whether a content type is worth reading and whether it
// needs the HTML reducer. An empty type is treated as HTML: servers that omit
// it are usually serving a page, and the reducer degrades to "strip nothing"
// on text that has no tags in it.
func readableKind(ctype string) (contentKind, bool) {
t := strings.ToLower(firstField(ctype))
switch {
case t == "":
return kindHTML, true
case strings.Contains(t, "html"), strings.Contains(t, "xml"):
return kindHTML, true
case strings.HasPrefix(t, "text/"):
return kindPlain, true
case strings.Contains(t, "json"), strings.Contains(t, "javascript"):
return kindPlain, true
}
return kindPlain, false
}
func firstField(ctype string) string {
if i := strings.IndexByte(ctype, ';'); i >= 0 {
ctype = ctype[:i]
}
return strings.TrimSpace(ctype)
}