mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* chore: remove code unreachable on every supported platform The Security and code health job's advisory deadcode step reports 152 unreachable functions on main. Running the same check under GOOS=linux, windows, and darwin shows 13 of those are false positives whose callers live in windows build-tagged files, so only functions unreachable on all three platforms are touched. Functions with no references anywhere are deleted, including the internal/reasoning package, which nothing imports. Functions that only tests reference move into per-package export_test.go files so tests keep their seams while the production binary sheds the code. Test call sites of deleted convenience wrappers are ported to the live APIs (scoped tool constructors, tools.DeferredSource, FinishReason checks). Kept despite the report: cron.Store.Runs (only reader of runs.jsonl, which production writes), and agenteval.TraceEventKeys and MissingTraceEvents (used across packages by the trace contract test). * docs(config): correct ValidateFile's errors.As claim Message is a flattened string (.Error() already called on the parse error), not a wrapped error, so callers can't recover the concrete json error type via errors.As. Fix the comment to match. Co-Authored-By: Claude Sonnet 5 <[email protected]> * chore(trace,skills): drop obsolete Sink adapters and fix stale doc references The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and TextSink exported with a contract nothing implements; remove the types with their contract. Also remove the stray blank line at EOF of trace.go flagged by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop directing callers to DiscoveryRoots/Duplicates, which are now test seams. Co-Authored-By: Claude Fable 5 <[email protected]> * chore: clean up remaining review nits on dead code removal Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf using %v, since the wrapped error was discarded immediately anyway. Remove a comment block that was duplicated verbatim. * docs(skills): drop remaining references to test-only List/Get Load and MergedSkills comments still named List/Get as production entry points after they moved to export_test.go. Point callers at Load and ListFromRoots instead. * chore: remove now-unreferenced export_test.go seams execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had no remaining callers after the sandbox unify-command-execution rebase; the production code they wrapped for tests no longer exists. --------- Co-authored-by: Claude Sonnet 5 <[email protected]>
606 lines
20 KiB
Go
606 lines
20 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
const (
|
|
// Raw-body budgets. HTML responses are converted to markdown before they
|
|
// reach the model (see web_fetch_markdown.go), so a generous raw budget
|
|
// does not translate into a generous context cost — conversion typically
|
|
// shrinks a page by an order of magnitude. 64KiB of raw HTML often held
|
|
// nothing but a page's <head>, which starved research tasks.
|
|
defaultWebFetchMaxBytes = 256 * 1024
|
|
maxWebFetchMaxBytes = 2 * 1024 * 1024
|
|
webFetchTimeout = 30 * time.Second
|
|
webFetchRedirectLimit = 5
|
|
webFetchErrorBodyLimit = 4 * 1024
|
|
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
|
|
)
|
|
|
|
type webFetchTool struct {
|
|
baseTool
|
|
client *http.Client
|
|
resolver webFetchResolver
|
|
}
|
|
|
|
type webFetchResolver interface {
|
|
LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error)
|
|
}
|
|
|
|
type webFetchDialer interface {
|
|
DialContext(ctx context.Context, network string, address string) (net.Conn, error)
|
|
}
|
|
|
|
type defaultWebFetchResolver struct{}
|
|
|
|
func (defaultWebFetchResolver) LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) {
|
|
return net.DefaultResolver.LookupNetIP(ctx, network, host)
|
|
}
|
|
|
|
type webFetchBlockedPrefix struct {
|
|
prefix netip.Prefix
|
|
reason string
|
|
}
|
|
|
|
type webFetchEmbeddedIPv4Prefix struct {
|
|
prefix netip.Prefix
|
|
byteOffset int
|
|
}
|
|
|
|
var webFetchBlockedAddrPrefixes = []webFetchBlockedPrefix{
|
|
{prefix: netip.MustParsePrefix("0.0.0.0/8"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("10.0.0.0/8"), reason: "private network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("100.64.0.0/10"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("127.0.0.0/8"), reason: "loopback hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("169.254.0.0/16"), reason: "link-local hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("172.16.0.0/12"), reason: "private network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("192.0.0.0/24"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("192.0.2.0/24"), reason: "documentation hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("192.88.99.0/24"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("192.168.0.0/16"), reason: "private network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("198.18.0.0/15"), reason: "benchmark network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("198.51.100.0/24"), reason: "documentation hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("203.0.113.0/24"), reason: "documentation hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("224.0.0.0/4"), reason: "multicast hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("240.0.0.0/4"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("::/128"), reason: "unspecified hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("::1/128"), reason: "loopback hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("100::/64"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("2001::/23"), reason: "special-use hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("2001:2::/48"), reason: "benchmark network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("2001:db8::/32"), reason: "documentation hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("fc00::/7"), reason: "private network hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("fe80::/10"), reason: "link-local hosts are blocked"},
|
|
{prefix: netip.MustParsePrefix("ff00::/8"), reason: "multicast hosts are blocked"},
|
|
}
|
|
|
|
var webFetchEmbeddedIPv4Prefixes = []webFetchEmbeddedIPv4Prefix{
|
|
{prefix: netip.MustParsePrefix("::/96"), byteOffset: 12},
|
|
{prefix: netip.MustParsePrefix("64:ff9b::/96"), byteOffset: 12},
|
|
{prefix: netip.MustParsePrefix("64:ff9b:1::/48"), byteOffset: 6},
|
|
{prefix: netip.MustParsePrefix("2002::/16"), byteOffset: 2},
|
|
}
|
|
|
|
func NewWebFetchTool() Tool {
|
|
return newWebFetchToolWithClientAndResolver(nil, defaultWebFetchResolver{})
|
|
}
|
|
|
|
func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetchResolver) Tool {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: webFetchTimeout}
|
|
}
|
|
return webFetchTool{
|
|
baseTool: baseTool{
|
|
name: "web_fetch",
|
|
description: "Fetch text from a public remote HTTP or HTTPS URL after network permission is granted. Do not use for localhost, private network URLs, or local dev servers; use bash with curl for those.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"url": {
|
|
Type: "string",
|
|
Description: "Public remote HTTP or HTTPS URL to fetch. Use bash with curl for localhost or private network URLs.",
|
|
},
|
|
"max_bytes": {
|
|
Type: "integer",
|
|
Description: "Maximum raw response body bytes to download before conversion.",
|
|
Default: defaultWebFetchMaxBytes,
|
|
Minimum: intPtr(1),
|
|
Maximum: intPtr(maxWebFetchMaxBytes),
|
|
},
|
|
"format": {
|
|
Type: "string",
|
|
Description: "auto (default): HTML responses are converted to compact markdown, everything else is returned as-is. raw: never convert. markdown: force conversion.",
|
|
Enum: []string{"auto", "raw", "markdown"},
|
|
Default: "auto",
|
|
},
|
|
},
|
|
Required: []string{"url"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: Safety{
|
|
SideEffect: SideEffectNetwork,
|
|
Permission: PermissionPrompt,
|
|
Reason: "Fetches remote URL content over the network.",
|
|
AdvertiseInAuto: true,
|
|
},
|
|
// Concurrent-safe: net/http.Client is documented as safe for
|
|
// concurrent use; each call issues independent request I/O.
|
|
// parallelSafeToolCall still requires PermissionAllow (prompted
|
|
// network fetches stay sequential until auto-allowed).
|
|
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: endpointResourceKeys},
|
|
},
|
|
client: client,
|
|
resolver: resolver,
|
|
}
|
|
}
|
|
|
|
func (tool webFetchTool) Run(ctx context.Context, args map[string]any) Result {
|
|
return tool.run(ctx, args)
|
|
}
|
|
|
|
func (tool webFetchTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
rawURL, err := stringArg(args, "url", "", true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error()), true
|
|
}
|
|
if _, err := intArg(args, "max_bytes", defaultWebFetchMaxBytes, 1, maxWebFetchMaxBytes); err != nil {
|
|
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error()), true
|
|
}
|
|
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
|
|
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
// RunWithSandbox follows the normal web_fetch path. The sandbox network policy
|
|
// gates sandboxed shell egress; web_fetch is an in-process tool guarded by the
|
|
// permission flow plus URL, redirect, host, and port safety checks.
|
|
func (tool webFetchTool) RunWithSandbox(ctx context.Context, args map[string]any, _ *zeroSandbox.Engine) Result {
|
|
return tool.run(ctx, args)
|
|
}
|
|
|
|
func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
|
rawURL, err := stringArg(args, "url", "", true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
|
}
|
|
maxBytes, err := intArg(args, "max_bytes", defaultWebFetchMaxBytes, 1, maxWebFetchMaxBytes)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
|
}
|
|
format, err := stringArg(args, "format", "auto", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
|
}
|
|
format = strings.ToLower(strings.TrimSpace(format))
|
|
switch format {
|
|
case "", "auto":
|
|
format = "auto"
|
|
case "raw", "markdown":
|
|
default:
|
|
return errorResult(`Error: Invalid arguments for web_fetch: format must be "auto", "raw", or "markdown".`)
|
|
}
|
|
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
|
|
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
|
|
}
|
|
|
|
parsedURL, err := validateWebFetchURL(ctx, rawURL, tool.resolver)
|
|
if err != nil {
|
|
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid URL for web_fetch: " + err.Error())
|
|
}
|
|
request.Header.Set("User-Agent", "zero-web-fetch/0.1")
|
|
request.Header.Set("Accept", "text/*, application/json, application/xhtml+xml, application/xml;q=0.9, */*;q=0.5")
|
|
|
|
client := tool.clientForRun()
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return errorResult("Error fetching URL: " + redactWebFetchText(err.Error()))
|
|
}
|
|
if response.Body == nil {
|
|
response.Body = io.NopCloser(strings.NewReader(""))
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
finalURL := redactWebFetchURL(parsedURL)
|
|
if response.Request != nil && response.Request.URL != nil {
|
|
finalURL = redactWebFetchURL(response.Request.URL)
|
|
}
|
|
contentType := redactWebFetchText(response.Header.Get("Content-Type"))
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
body, _, _ := readWebFetchBody(response.Body, min(maxBytes, webFetchErrorBodyLimit))
|
|
message := fmt.Sprintf("Error fetching URL: HTTP %s", webFetchStatusLine(response))
|
|
if strings.TrimSpace(body) != "" {
|
|
message += "\n\n" + redactWebFetchText(body)
|
|
}
|
|
return Result{
|
|
Status: StatusError,
|
|
Output: message,
|
|
Meta: map[string]string{
|
|
"url": finalURL,
|
|
"status_code": strconv.Itoa(response.StatusCode),
|
|
"content_type": contentType,
|
|
},
|
|
}
|
|
}
|
|
|
|
body, truncated, err := readWebFetchBody(response.Body, maxBytes)
|
|
if err != nil {
|
|
return errorResult("Error reading response body: " + redactWebFetchText(err.Error()))
|
|
}
|
|
body = redactWebFetchText(body)
|
|
|
|
// HTML responses are converted to compact markdown by default: raw HTML is
|
|
// mostly markup, so conversion typically shrinks the page by an order of
|
|
// magnitude and the model reads content instead of boilerplate. format=raw
|
|
// is the escape hatch for pages the converter mangles.
|
|
converted := false
|
|
if format == "markdown" || (format == "auto" && looksLikeHTML(contentType, body)) {
|
|
if markdown := htmlToMarkdown(body); markdown != "" {
|
|
body = markdown
|
|
converted = true
|
|
}
|
|
}
|
|
|
|
headers := []string{
|
|
"URL: " + finalURL,
|
|
"Status: " + webFetchStatusLine(response),
|
|
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
|
|
"Bytes: " + strconv.Itoa(len(body)),
|
|
}
|
|
if converted {
|
|
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
|
|
}
|
|
output := strings.Join(append(headers, "", body), "\n")
|
|
|
|
return Result{
|
|
Status: StatusOK,
|
|
Output: output,
|
|
Truncated: truncated,
|
|
Meta: map[string]string{
|
|
"url": finalURL,
|
|
"status_code": strconv.Itoa(response.StatusCode),
|
|
"content_type": contentType,
|
|
"bytes": strconv.Itoa(len(body)),
|
|
"truncated": strconv.FormatBool(truncated),
|
|
"converted": strconv.FormatBool(converted),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (tool webFetchTool) clientForRun() http.Client {
|
|
if tool.client == nil {
|
|
return http.Client{
|
|
Timeout: webFetchTimeout,
|
|
Transport: webFetchSafeTransport(nil, tool.resolver),
|
|
CheckRedirect: webFetchRedirectPolicy(nil, tool.resolver),
|
|
}
|
|
}
|
|
client := *tool.client
|
|
client.Transport = webFetchSafeTransport(client.Transport, tool.resolver)
|
|
client.CheckRedirect = webFetchRedirectPolicy(tool.client.CheckRedirect, tool.resolver)
|
|
return client
|
|
}
|
|
|
|
func webFetchSafeTransport(roundTripper http.RoundTripper, resolver webFetchResolver) http.RoundTripper {
|
|
var transport *http.Transport
|
|
switch typed := roundTripper.(type) {
|
|
case nil:
|
|
transport = http.DefaultTransport.(*http.Transport).Clone()
|
|
case *http.Transport:
|
|
transport = typed.Clone()
|
|
default:
|
|
return roundTripper
|
|
}
|
|
|
|
dialer := &net.Dialer{Timeout: webFetchTimeout, KeepAlive: 30 * time.Second}
|
|
transport.Proxy = nil
|
|
transport.DialContext = webFetchSafeDialContext(resolver, dialer)
|
|
transport.DialTLSContext = nil
|
|
return transport
|
|
}
|
|
|
|
func webFetchSafeDialContext(resolver webFetchResolver, dialer webFetchDialer) func(context.Context, string, string) (net.Conn, error) {
|
|
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
|
pinnedAddress, err := webFetchSafeDialAddress(ctx, resolver, address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dialer.DialContext(ctx, network, pinnedAddress)
|
|
}
|
|
}
|
|
|
|
func webFetchSafeDialAddress(ctx context.Context, resolver webFetchResolver, address string) (string, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return "", fmt.Errorf("validate dial target: %w", err)
|
|
}
|
|
addrs, err := resolveWebFetchHostAddrs(ctx, host, firstWebFetchResolver(resolver), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(addrs) == 0 {
|
|
return "", fmt.Errorf("host did not resolve to an IP address")
|
|
}
|
|
return net.JoinHostPort(addrs[0].String(), port), nil
|
|
}
|
|
|
|
func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error, resolver webFetchResolver) func(*http.Request, []*http.Request) error {
|
|
return func(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= webFetchRedirectLimit {
|
|
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
|
|
}
|
|
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
|
|
return fmt.Errorf("unsafe redirect URL: %w", err)
|
|
}
|
|
if previous != nil {
|
|
return previous(request, via)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func readWebFetchBody(body io.Reader, maxBytes int) (string, bool, error) {
|
|
raw, err := io.ReadAll(io.LimitReader(body, int64(maxBytes)+1))
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
truncated := len(raw) > maxBytes
|
|
if truncated {
|
|
raw = raw[:maxBytes]
|
|
}
|
|
return string(raw), truncated, nil
|
|
}
|
|
|
|
func validateWebFetchURL(ctx context.Context, rawURL string, resolver webFetchResolver) (*url.URL, error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("url is required")
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateParsedWebFetchURL(ctx, parsed, resolver); err != nil {
|
|
return nil, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func validateWebFetchURLBeforePermission(rawURL string) error {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return fmt.Errorf("url is required")
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validateParsedWebFetchURLBeforePermission(parsed)
|
|
}
|
|
|
|
func validateParsedWebFetchURL(ctx context.Context, parsed *url.URL, resolver webFetchResolver) error {
|
|
if err := validateParsedWebFetchURLBeforePermission(parsed); err != nil {
|
|
return err
|
|
}
|
|
host := strings.TrimSpace(parsed.Hostname())
|
|
if err := rejectUnsafeWebFetchHost(ctx, host, resolver); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateParsedWebFetchURLBeforePermission(parsed *url.URL) error {
|
|
if parsed == nil {
|
|
return fmt.Errorf("url is required")
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf("only http and https URLs are supported")
|
|
}
|
|
if parsed.User != nil {
|
|
return fmt.Errorf("URLs with user info are not allowed")
|
|
}
|
|
host := strings.TrimSpace(parsed.Hostname())
|
|
if host == "" {
|
|
return fmt.Errorf("URL host is required")
|
|
}
|
|
if strings.Contains(host, "%") {
|
|
return fmt.Errorf("URL host zones are not allowed")
|
|
}
|
|
if err := rejectUnsafeWebFetchLiteralHost(host); err != nil {
|
|
return err
|
|
}
|
|
if err := validateWebFetchPort(parsed); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateWebFetchPort(parsed *url.URL) error {
|
|
port := parsed.Port()
|
|
if port == "" {
|
|
return nil
|
|
}
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "http":
|
|
if port == "80" {
|
|
return nil
|
|
}
|
|
case "https":
|
|
if port == "443" {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("only default ports are allowed for %s URLs", parsed.Scheme)
|
|
}
|
|
|
|
func rejectUnsafeWebFetchLiteralHost(host string) error {
|
|
normalized := strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".")
|
|
if normalized == "" {
|
|
return fmt.Errorf("URL host is required")
|
|
}
|
|
switch {
|
|
case normalized == "localhost" || strings.HasSuffix(normalized, ".localhost"):
|
|
return errors.New(webFetchPublicOnlyHint)
|
|
case normalized == "metadata" || normalized == "metadata.google.internal":
|
|
return errors.New(webFetchPublicOnlyHint)
|
|
case strings.HasSuffix(normalized, ".local"):
|
|
return errors.New(webFetchPublicOnlyHint)
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(normalized)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := rejectUnsafeWebFetchAddr(addr); err != nil {
|
|
return errors.New(webFetchPublicOnlyHint)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rejectUnsafeWebFetchHost(ctx context.Context, host string, resolver webFetchResolver) error {
|
|
_, err := resolveWebFetchHostAddrs(ctx, host, resolver, false)
|
|
return err
|
|
}
|
|
|
|
func resolveWebFetchHostAddrs(ctx context.Context, host string, resolver webFetchResolver, requireResolver bool) ([]netip.Addr, error) {
|
|
normalized := strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".")
|
|
if normalized == "" {
|
|
return nil, fmt.Errorf("URL host is required")
|
|
}
|
|
switch {
|
|
case normalized == "localhost" || strings.HasSuffix(normalized, ".localhost"):
|
|
return nil, fmt.Errorf("localhost hosts are blocked")
|
|
case normalized == "metadata" || normalized == "metadata.google.internal":
|
|
return nil, fmt.Errorf("metadata service hosts are blocked")
|
|
case strings.HasSuffix(normalized, ".local"):
|
|
return nil, fmt.Errorf("local network hosts are blocked")
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(normalized)
|
|
if err == nil {
|
|
if err := rejectUnsafeWebFetchAddr(addr); err != nil {
|
|
return nil, err
|
|
}
|
|
return []netip.Addr{addr.Unmap()}, nil
|
|
}
|
|
if resolver == nil {
|
|
if requireResolver {
|
|
return nil, fmt.Errorf("host resolver is required")
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
addrs, err := resolver.LookupNetIP(ctx, "ip", normalized)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve host: %w", err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, fmt.Errorf("host did not resolve to an IP address")
|
|
}
|
|
for _, addr := range addrs {
|
|
if err := rejectUnsafeWebFetchAddr(addr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return addrs, nil
|
|
}
|
|
|
|
func rejectUnsafeWebFetchAddr(addr netip.Addr) error {
|
|
if !addr.IsValid() {
|
|
return fmt.Errorf("invalid resolved host address")
|
|
}
|
|
addr = addr.Unmap()
|
|
if embedded, ok := webFetchEmbeddedIPv4(addr); ok {
|
|
return rejectUnsafeWebFetchAddr(embedded)
|
|
}
|
|
for _, blocked := range webFetchBlockedAddrPrefixes {
|
|
if blocked.prefix.Contains(addr) {
|
|
return errors.New(blocked.reason)
|
|
}
|
|
}
|
|
if !addr.IsGlobalUnicast() {
|
|
return fmt.Errorf("non-global hosts are blocked")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func webFetchEmbeddedIPv4(addr netip.Addr) (netip.Addr, bool) {
|
|
if addr.Is4() {
|
|
return netip.Addr{}, false
|
|
}
|
|
bytes := addr.As16()
|
|
for _, candidate := range webFetchEmbeddedIPv4Prefixes {
|
|
if !candidate.prefix.Contains(addr) {
|
|
continue
|
|
}
|
|
if candidate.byteOffset < 0 || candidate.byteOffset+4 > len(bytes) {
|
|
continue
|
|
}
|
|
embedded := [4]byte{
|
|
bytes[candidate.byteOffset],
|
|
bytes[candidate.byteOffset+1],
|
|
bytes[candidate.byteOffset+2],
|
|
bytes[candidate.byteOffset+3],
|
|
}
|
|
return netip.AddrFrom4(embedded), true
|
|
}
|
|
return netip.Addr{}, false
|
|
}
|
|
|
|
func redactWebFetchURL(value *url.URL) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return redactWebFetchText(value.String())
|
|
}
|
|
|
|
func redactWebFetchText(value string) string {
|
|
return redaction.RedactString(value, redaction.Options{})
|
|
}
|
|
|
|
func webFetchStatusLine(response *http.Response) string {
|
|
if response == nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(response.Status) != "" {
|
|
return response.Status
|
|
}
|
|
return strings.TrimSpace(strconv.Itoa(response.StatusCode) + " " + http.StatusText(response.StatusCode))
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstWebFetchResolver(resolver webFetchResolver) webFetchResolver {
|
|
if resolver != nil {
|
|
return resolver
|
|
}
|
|
return defaultWebFetchResolver{}
|
|
}
|