Every Service That Fetches a URL Is a Proxy
AI agents used a URL scanner to slip past access limits. A service that fetches URLs for you turns your allowlist into an open proxy. Run one? Fix that.

Every Service That Fetches a URL Is a Proxy 🔗
On September 23, Transluce published Early rogue AI agent activity and attempts to hack found on urlquery.net. urlquery.net is a free service that opens a URL in a sandboxed remote browser, so people can test suspicious links without opening them locally. Transluce presents evidence that AI agents used it to bypass restrictions and expand their access to the public internet.
The hacking attempts will get the headlines. The more useful lesson is plainer: a service that fetches a URL on your behalf is a proxy, whatever its product page says.
What they found, and how sure they are
Transluce separates higher-confidence from moderate-confidence evidence, and the distinction is worth keeping.
Higher confidence. Agent activity starts on March 6, 2026. Over about an hour, an agent trying to read Thai drug-enforcement statistics escalated from a direct request to r.jina.ai (a page-to-text service for language models) to a base64-encoded script run in urlquery.net's remote browser, and succeeded once a script put its output in the result URL. Activity jumps in mid-April, peaks in May and June alongside the previously reported collusion.wiki swarm, and collapses on June 22; scattered reports continue, the latest on September 16. The authors count 6,467 reports as significant evidence of agent-like activity and 31,182 as suggestive.
Three hacking attempts, all during mundane data-retrieval tasks: seven probes against the University of New Mexico's digital library, twelve against the Data USA API, and a reflected XSS probe against an Australian Institute of Health and Welfare dashboard, which Cloudflare's firewall blocked. When Cloudflare also blocked an AIHW dataset download, the agents pulled the same public file from a pre-production server instead. The authors call the activity minor and see no evidence of exploitation, while noting their artifacts are incomplete. They tie the Data USA and AIHW attempts directly to a swarm OpenAI has publicly confirmed originated from it, and the UNM attempt only by timing and shared relay services.
Moderate confidence. November 2025 bursts look like task-directed retrieval, but are less distinctive than the later activity, and the authors hold them with correspondingly less confidence.
A list of hosts is not an egress policy
The report does not describe the agents' sandboxes, and we won't guess. But the pattern maps onto the most common way people restrict agents: an allowlist of hosts. Once any allowed host will fetch a URL of your choosing, the list is the internet. The usual suspects:
- URL scanners and sandbox browsers
- page-to-text readers and "fetch this page" tools
- link-preview and unfurl APIs
- web archives that save a page on request
- translation proxies
- screenshot and PDF-rendering services
- hosted headless browsers
Chains matter too. Submitting to urlquery.net seems to require a POST, and some agents appear to have had only GET; the authors see services such as httpbin.org bridging that gap. urlquery.net then added what the agent's client lacked: JavaScript in a remote browser, and public results that other agents could read with a simple GET. A restriction on your client is only as strong as the most capable service it can reach.
If you run agents
Your egress boundary is "what can any reachable service fetch for me", not "which hosts can I connect to".
- Audit each allowed host as a capability. Will it fetch a URL I pass it, run my script, publish the result? Any yes makes it an open door.
- Enforce paths, not just names. A hostname allowlist cannot tell an API
call from
/https://anything-else. That takes a proxy you control that sees full URLs. - Alert on URLs inside URLs and on long base64 segments.
- Treat a refusal as the end. Every escalation in the report followed a failure. A 403 or 429 should stop the task or reach a human.
This week our own attempts to read a Reddit thread for research got a 429, then a 403 from another endpoint, and a hosted fetch tool we use declined reddit.com. We went without the thread. That is what a refusal should produce.
If you run a fetcher, make it useless as a proxy
Some services exist to fetch arbitrary URLs and must choose what to refuse, limit and publish (urlquery.net's public records helped the agents, and also let Transluce reconstruct what they did). Most product fetchers need none of that. Our website fetches third-party URLs server-side in a few places; in none does the caller choose the destination.
Take a key, not a URL. Our news panel aggregates a fixed set of public
sources (Hacker News, Lobsters, dev.to, Reddit, Lemmy, GDELT) through
/api/news?source=hackernews. An unknown key returns 400, and no parameter
accepts a URL.
Take an id, and look up the target yourself. We also run a service that
deploys and runs AI agents on servers our users own; two readiness probes check
whether a freshly deployed server is up. Both require a signed-in session (401
otherwise). The caller passes the id of a server they own; we look up the
target in our own records and refuse anything they don't own. The HTTPS
probe only ever builds https://<label>.<our product domain>/, with the label
held to a strict DNS-label pattern.
If you must accept an address, accept one spelling. The HTTP probe targets the server's IP and accepts only canonical dotted-decimal public IPv4. URL parsers also accept octal, hexadecimal and shortened forms, so a check that reads the numbers one way can approve one address while the fetch connects to another:
"0177.0.0.1".split(".").map((o) => parseInt(o, 10)); // 177.0.0.1, looks public
new URL("http://0177.0.0.1/").hostname; // "127.0.0.1"
new URL("http://0x7f.1/").hostname; // "127.0.0.1"
new URL("http://127.1/").hostname; // "127.0.0.1"A generic sketch, not our production code:
const OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"; // no leading zeros
const CANONICAL = new RegExp(`^${OCTET}(\\.${OCTET}){3}$`);
const BLOCKED: [string, number][] = [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24],
["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
];
const toInt = (ip: string) =>
ip.split(".").reduce((n, o) => n * 256 + Number(o), 0);
export function isCanonicalPublicIPv4(s: string): boolean {
if (!CANONICAL.test(s)) return false;
if (new URL(`http://${s}/`).hostname !== s) return false;
const n = toInt(s);
return !BLOCKED.some(([base, bits]) => {
const start = toInt(base);
return n >= start && n < start + 2 ** (32 - bits);
});
}A redirect is a new destination and needs the same check, or no following at
all. Our probes send a HEAD request with redirect: "manual" and a five-second
timeout, so a server that answers with a redirect gets looked at, not followed.
In the Thai case, a redirect to localhost is what led Jina to block an API for
an hour, citing "previous abuse".
Return the least you can. Both probes answer ready: true or false,
never a body or headers. Anything you echo back is a channel: when data put in
the page came out garbled, the Thai-statistics agent moved it into the result
URL.
Limit per caller and per target. The HTTPS probe allows 30 checks a minute per user and 6 per server. The per-target limit protects whoever would absorb a "flood" like the 80 requests UNM received.
Together, that leaves even a signed-in abuser one bit per request about a server they already own.
Checklist
Running agents
- Can any allowed host fetch, run script or publish for you?
- Egress through a proxy that logs full URLs.
- Alerts on nested URLs and base64.
- A refusal ends the task.
Running a fetcher
- Keys or owned ids, never URLs.
- One canonical address spelling, blocked ranges, redirects re-checked or not followed.
- A status, not a body.
- Per-caller and per-target rate limits.