Summary
The fetch tool dials the model-supplied URL directly with no validation of the
resolved address. A caller can point it at loopback, link-local, private-range,
or cloud instance-metadata endpoints (e.g. http://169.254.169.254/latest/meta-data/)
and it will fetch them (CWE-918).
Where
pkg/fetcher/fetcher.go (fetchURL, ~line 77-90): builds the request straight
from the input URL and calls http.NewRequest / httpClient.Do with no IP
classification or scheme restriction. The only pre-check is robots.txt
(politeness, not security). The existing //nolint:gosec on the Do call
("This is a fetch server; fetching user-provided URLs is its core purpose")
acknowledges the behavior but doesn't mitigate it.
pkg/server/server.go (NewFetchServer, ~line 36-49): the http.Client is
constructed with only a Timeout (and an optional proxy) — nothing on
Transport.DialContext to gate the resolved peer address.
- The toolhive registry manifest for this server also declares
"network": {"outbound": {"allow_port": [443], "insecure_allow_all": true}},
so there's no compensating network-level restriction either. Note a
NetworkPolicy alone wouldn't close this anyway — the cloud-metadata route
isn't pod-to-pod traffic a NetworkPolicy governs.
Suggested fix
Add a Control hook (or a custom net.Dialer) on the http.Client's
Transport that classifies the resolved peer IP before connecting, blocking:
- loopback (unless explicitly opted in for local/dev use)
- link-local unicast (covers
169.254.0.0/16, including the AWS/GCP/Azure/Oracle
metadata endpoint)
- multicast
- well-known cloud metadata addresses outside the link-local range that some
policies would otherwise miss (AWS IMDSv2-over-IPv6 fd00:ec2::254, Alibaba
100.100.100.200)
- optionally private/RFC1918 ranges, if this server is ever expected to run
somewhere those addresses are untrusted
This should run against the resolved address, not just the literal host in
the URL, to defend against DNS rebinding.
Context
Found while evaluating this server as a fetch-tool backend for
stacklok/atrium. Happy to contribute the
fix if useful — Atrium already carries a small, tested internal/ssrf package
implementing exactly this policy that could serve as a reference.
Summary
The
fetchtool dials the model-supplied URL directly with no validation of theresolved address. A caller can point it at loopback, link-local, private-range,
or cloud instance-metadata endpoints (e.g.
http://169.254.169.254/latest/meta-data/)and it will fetch them (CWE-918).
Where
pkg/fetcher/fetcher.go(fetchURL, ~line 77-90): builds the request straightfrom the input URL and calls
http.NewRequest/httpClient.Dowith no IPclassification or scheme restriction. The only pre-check is
robots.txt(politeness, not security). The existing
//nolint:gosecon theDocall("This is a fetch server; fetching user-provided URLs is its core purpose")
acknowledges the behavior but doesn't mitigate it.
pkg/server/server.go(NewFetchServer, ~line 36-49): thehttp.Clientisconstructed with only a
Timeout(and an optional proxy) — nothing onTransport.DialContextto gate the resolved peer address."network": {"outbound": {"allow_port": [443], "insecure_allow_all": true}},so there's no compensating network-level restriction either. Note a
NetworkPolicy alone wouldn't close this anyway — the cloud-metadata route
isn't pod-to-pod traffic a NetworkPolicy governs.
Suggested fix
Add a
Controlhook (or a customnet.Dialer) on thehttp.Client'sTransportthat classifies the resolved peer IP before connecting, blocking:169.254.0.0/16, including the AWS/GCP/Azure/Oraclemetadata endpoint)
policies would otherwise miss (AWS IMDSv2-over-IPv6
fd00:ec2::254, Alibaba100.100.100.200)somewhere those addresses are untrusted
This should run against the resolved address, not just the literal host in
the URL, to defend against DNS rebinding.
Context
Found while evaluating this server as a fetch-tool backend for
stacklok/atrium. Happy to contribute the
fix if useful — Atrium already carries a small, tested
internal/ssrfpackageimplementing exactly this policy that could serve as a reference.