What
fetchReadable / the fetch_url tool in lib/web.ts validates that a URL's hostname doesn't resolve to a private/internal address before issuing the request. This blocks the obvious SSRF case (LLM follows a search result link to localhost, 169.254.169.254, RFC1918, etc.).
It does not close DNS rebinding. The hostname is resolved twice:
- By our pre-flight
dns.lookup() in validatePublicHttpUrl — used only for the public-IP check.
- By Node's
fetch() at connect time — what the actual TCP connection uses.
A rebinding-attacker DNS server can return a public IP for the first lookup and a private IP (e.g. 10.0.0.1, 169.254.169.254) for the second.
Why we shipped v0.1 with the gap
- Self-hosted, typically single-user or small-team.
- Attack requires: (a) the LLM to follow a link to an attacker-controlled domain, AND (b) that domain to be running an active rebinding flip.
- The current code already blocks the realistic 99% case.
Fix path
Pin the resolved IP at connect time. Cleanest in Node is undici with a custom `connect`:
```ts
import { Agent } from "undici";
const safeAgent = new Agent({
connect: { lookup: (hostname, opts, cb) => {
// re-validate here, then return the same address fetch will actually use
}},
});
```
Then pass via `fetch(url, { dispatcher: safeAgent })`. The pre-flight check in `validatePublicHttpUrl` becomes redundant once the connect-time check is in place.
Acceptance
What
fetchReadable/ thefetch_urltool inlib/web.tsvalidates that a URL's hostname doesn't resolve to a private/internal address before issuing the request. This blocks the obvious SSRF case (LLM follows a search result link tolocalhost,169.254.169.254, RFC1918, etc.).It does not close DNS rebinding. The hostname is resolved twice:
dns.lookup()invalidatePublicHttpUrl— used only for the public-IP check.fetch()at connect time — what the actual TCP connection uses.A rebinding-attacker DNS server can return a public IP for the first lookup and a private IP (e.g.
10.0.0.1,169.254.169.254) for the second.Why we shipped v0.1 with the gap
Fix path
Pin the resolved IP at connect time. Cleanest in Node is undici with a custom `connect`:
```ts
import { Agent } from "undici";
const safeAgent = new Agent({
connect: { lookup: (hostname, opts, cb) => {
// re-validate here, then return the same address fetch will actually use
}},
});
```
Then pass via `fetch(url, { dispatcher: safeAgent })`. The pre-flight check in `validatePublicHttpUrl` becomes redundant once the connect-time check is in place.
Acceptance