(ctx context.Context, params ToolArgs)
| 77 | } |
| 78 | |
| 79 | func (h *fetchHandler) CallTool(ctx context.Context, params ToolArgs) (*tools.ToolCallResult, error) { |
| 80 | if len(params.URLs) == 0 { |
| 81 | return nil, errors.New("at least one URL is required") |
| 82 | } |
| 83 | |
| 84 | // Decorate the active runtime.tool.handler span with the requested |
| 85 | // URLs. Strip query params and userinfo first: query strings often |
| 86 | // carry signed-URL tokens, OAuth codes, or session IDs, and userinfo |
| 87 | // carries credentials inline. The path stays intact so dashboards |
| 88 | // can still answer "which sites/endpoints did the agent hit?" — the |
| 89 | // HTTP CLIENT child span emitted by `httpclient.WrapWithOTel` below |
| 90 | // retains the full URL under `http.url` for callers that opt into |
| 91 | // that backend's full-URL capture. |
| 92 | if span := trace.SpanFromContext(ctx); span.IsRecording() { |
| 93 | attrs := []attribute.KeyValue{ |
| 94 | attribute.Int("cagent.tool.fetch.url_count", len(params.URLs)), |
| 95 | attribute.StringSlice("cagent.tool.fetch.urls", sanitizeFetchURLs(params.URLs)), |
| 96 | } |
| 97 | if params.Format != "" { |
| 98 | attrs = append(attrs, attribute.String("cagent.tool.fetch.format", params.Format)) |
| 99 | } |
| 100 | span.SetAttributes(attrs...) |
| 101 | } |
| 102 | |
| 103 | // Transport: by default we install [httpclient.SSRFDialControl] on the |
| 104 | // dialer so the fetch tool refuses connections to loopback, RFC1918, |
| 105 | // link-local (incl. cloud metadata at 169.254.169.254), multicast and |
| 106 | // the unspecified address — even when DNS for an otherwise-public host |
| 107 | // resolves there. Operators who legitimately need to call internal |
| 108 | // services opt in via `allow_private_ips: true`. |
| 109 | var transport http.RoundTripper = httpclient.NewSSRFSafeTransport() |
| 110 | if h.allowPrivateIPs { |
| 111 | transport = http.DefaultTransport |
| 112 | } |
| 113 | |
| 114 | headers := h.expander.ExpandMap(ctx, h.headers) |
| 115 | |
| 116 | client := &http.Client{ |
| 117 | Timeout: h.timeout, |
| 118 | Transport: httpclient.WrapWithOTel(transport), |
| 119 | // Re-check the domain allow/deny lists on every redirect: without this, |
| 120 | // an allowed origin could redirect into a denied one and bypass the |
| 121 | // policy. The 10-redirect cap mirrors the net/http default. |
| 122 | CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| 123 | if len(via) >= 10 { |
| 124 | return errors.New("stopped after 10 redirects") |
| 125 | } |
| 126 | // Strip caller-supplied headers when redirecting to a different |
| 127 | // host so credentials (Authorization, X-Api-Key, ...) never leak |
| 128 | // to a third-party host. Go's stdlib already strips a small |
| 129 | // allow-list (Authorization, WWW-Authenticate, Cookie) on cross- |
| 130 | // domain redirects but does NOT strip arbitrary custom headers, |
| 131 | // so we strip everything the operator configured. via[0] is the |
| 132 | // original request; comparing req.URL.Host against it (rather |
| 133 | // than the previous hop) guarantees that headers cannot reappear |
| 134 | // after a chain like A -> B -> A. |
| 135 | if origHost := via[0].URL.Host; origHost != req.URL.Host { |
| 136 | for k := range headers { |
nothing calls this directly
no test coverage detected