mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
607 lines
20 KiB
Go
607 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.",
|
|
deferred: true,
|
|
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{}
|
|
}
|