Where an agent’s URL actually connects
Following an agent's URL past the allowlist: resolving every address, pinning the connection, vetting each redirect, and why each browser backend differs.

Telling an agent which URLs it may open sounds like a complete security statement until you follow one of those URLs down to a socket. The permission is written about a string. The connection goes to an IP address, which a resolver picks at the moment of asking and may pick differently the next time, the response may redirect somewhere else entirely, and if the thing opening the URL is a browser, the rules depend on which browser it is and where it runs. This is server-side request forgery territory, and OWASP’s cheat sheet is the place to start on the general problem, but agents make it more pressing, because an agent’s next URL can come from the page it has just read.
Between May and August 2026 I hardened the outbound paths of our agent platform: MCP and OAuth requests, the gateway’s own fetches, remote file blobs and two browser backends. Playwright and Cloudflare’s hosted browser did the browsing and Node and Bun did the HTTP, so the work lay in what sits between them and the network. The paths didn’t all end up with the same guarantees, and those differences are the useful part.
The first check was on the name itself. A shared function normalised the host and refused private and local-only destinations, including disallowed IPv4 and IPv6 ranges. The tests distinguish those from public addresses and names such as 8.8.8.8 and example.com. A name that passed still had to be resolved, and here the code asked for every address instead of the first. One bad record failed the whole name, because a hostname that answers with both a public address and 169.254.169.254, the well-known cloud metadata address, has told you what you need to know about it. An empty answer or a resolver error failed too, so being unable to find out counted as no.
That check on its own leaves a gap. Consider a bootstrap downloader that rejects a URL if its name resolves to a loopback, private, link-local, multicast or unspecified address, and also refuses failed or empty lookups. It then downloads with curl -fsSL <url> -o <path> && chmod +x <path>. The first step is a real guard against an internal destination, but curl does its own DNS lookup and, with -L, follows redirects. A name that answers differently a moment later, or a public URL that redirects to a private one, could pass the initial check and send the download somewhere the validator never approved.
On the gateway’s MCP and OAuth path, the connection used address pinning. A client that checks a name and then hands the URL to an HTTP library has resolved it twice, once to check and once to connect, and private.example is free to answer with a public address the first time and 169.254.169.254 the second. So after vetting, each request got a custom lookup function, the hook the HTTP client would normally use to ask DNS for an address, and that function returned the first vetted address without asking anyone. The hostname doesn’t disappear from the wire. The Host header and the TLS SNI field still carry it, so the far end sees an ordinary request for that name, but the TCP connection goes to the address that was checked. A test with a stub resolver answering 8.8.4.4 asserts that the request is pinned to 8.8.4.4. The hook also had to respect the runtime’s calling convention: Bun can request an array of addresses rather than a single one. The adapter returns the same vetted address in either shape, because a control built on a connection hook is only as portable as the hook.
Redirects on this path got the same treatment. Requests set redirect: "manual", so each redirect came back to the code instead of being followed by the client. A target on another origin was refused with “redirect target must stay on the original origin”, a target on the same origin was resolved, vetted and pinned as a new hop, and after five redirects the request failed. The tests show what that’s for: a 302 to a link-local address leaves the list of calls holding only the original URL, a cross-origin 302 on a request carrying an Authorization header is refused, and a same-origin 307 is followed. Same-origin only is stricter than most of the web would tolerate, and it’s reasonable here because these requests can carry credentials meant for one server.
That was the strongest version, and only this path had it. A gateway fetch or a blob download that checks a name and then calls plain fetch still has the resolve-twice gap, however carefully it checks.
| Path | Checked before connecting | Connects to the vetted address? |
|---|---|---|
| MCP and OAuth requests | Host, every resolved address, every redirect hop | Yes |
| Gateway fetches | Credentials and scheme, host and DNS, same-origin redirect hops | No |
| Remote file blobs | Resolved host, with redirects handled manually up to a limit | No |
| Hosted browser | HTTPS, no credentials in the URL, DNS check, route handler on page requests | No |
| Local browser | Nothing at the URL layer | No |
Browsers land on the bottom two rows because a browser makes its own connections and does its own resolving, so the address the check approved is not necessarily the one the browser reaches. The two backends also did different jobs. The hosted one ran on Cloudflare’s browser service, driven over the Chrome DevTools Protocol, and it was there to look at the public web, so its rule was strict: navigation had to be HTTPS, carry no credentials in the URL and pass the DNS check, which is how 10.0.0.8 and file:///etc/passwd get refused in its tests. A route handler on **/* extended that to the requests the page made for itself, let about:, blob: and data: through because they have no network destination to check, and aborted anything else it refused with blockedbyclient and a security log line. The connection that drove the browser got the same suspicion: the client wouldn’t accept a webSocketDebuggerUrl unless it equalled the URL the client had computed itself. The page-reading tools on this backend, for screenshots, Markdown and scraping, rejected non-public URLs before calling anything.
The local backend was the default. It ran Playwright inside the sandbox, the same check returned immediately without looking at the URL, and a test asserts that file:///tmp/app.html is allowed. I think that’s right for the job, since this is the browser an agent uses to look at what it has just built, and a rule against local files and private addresses would stop it doing that. It does mean that on the default backend the answer to “where may this browser connect?” is “wherever the sandbox’s network lets it”. The checks above have nothing to say about that, so the answer is only as good as the sandbox.
The MCP path and the gateway’s own fetch also had environment flags for private-network URLs and plain HTTP, and the MCP refusal messages named the flag that would relax them. A self-hosted MCP server on an internal network is a legitimate reason to set one. What matters is who holds the switch: a flag is set by whoever runs the gateway and not by an agent or a tool server supplying a URL. With one set, the checks it relaxes stop applying, so a deployment’s real rules are the ones above minus whatever its flags switch off.
The result is narrower than SSRF prevention: a specific set of rules for each path that you can read and test. If I were reviewing a system like this, I’d spend less time on which URLs the agent may open and more on which code opens them, and on what that code does between the check and the socket.
Chris Chabot, November 2026