Agents that fetch URLs: SSRF-safe tools and media by default

How fetch_url and client-supplied media links are limited to public addresses in 10xGraph: pinned connections, checked redirects, MediaFetchPolicy and a pre-run 400.

An agent that browses or reads files from links sends requests that you did not write, to hosts that a model or a user chose. On a server, that is a request-forgery risk: if the URL points at 127.0.0.1, at a private network address or at a cloud metadata endpoint, the server fetches it from inside your network and may hand the answer back to the model, and from there to the user.

10xGraph is open-source, self-hosted infrastructure for AI applications. You write the agents, tools and tasks; 10xGraph runs them behind an API server with authentication, role-based access, tenant isolation, durable state in Postgres and Redis, background jobs, metrics and tracing, and generates Docker and Kubernetes deploy files. MIT licensed, by 10xScale. Formerly Agentflow.

Two places in 10xGraph fetch a URL on behalf of someone else: the prebuilt fetch_url tool, and the media path that downloads a link a client put in a message. Both use the same small HTTP client with fixed rules. This post lists the rules, the policy you can tune, and the check the API does before a run starts. Reference: Web tools and the security model.

What the rules are

The shared client (tenxgraph.utils.safe_http) follows these rules, and none of them can be turned off:

  • Only http and https. file: and every other scheme is refused.
  • The host name is resolved once, and every address it resolves to must be globally routable. That rejects loopback, private ranges, link-local addresses such as the metadata address 169.254.169.254, the shared address space 100.64.0.0/10, and the NAT64 and IPv4-compatible forms of internal addresses.
  • The connection goes to the address that was checked, not to the name again, so DNS cannot answer differently the second time.
  • Redirects are followed by hand, one hop at a time, 5 at most, and each hop goes through the same checks. A public page that redirects to an internal address is refused at that hop.
  • No proxy environment variables are read.
  • The body has a size cap.

fetch_url

fetch_url is a prebuilt tool with the tags web, fetch and network. It returns normalised page text, caps max_chars at 20,000, clamps the timeout to between 1 and 30 seconds, and runs the blocking client in a worker thread so it does not stall the event loop.

Two attacks matter for a fetch tool. A public page can redirect to an internal address, and DNS rebinding can change the answer between the check and the connection. Hop-by-hop checks and the pinned connection close both. If you want the tool limited to named sites, create_fetch_url(allowed_hosts) builds a fetch_url that only fetches those hosts, and the address rules still apply on top.

Some model providers (OpenAI, Anthropic) fetch a media URL themselves. On other paths, notably Google GenAI, the Google File API and inline bytes, your server downloads it. That URL came from a client, so it is untrusted input. A MediaFetchPolicy says what may be downloaded:

Field Default
enabled True
allowed_schemes ("https", "http")
allowed_hosts empty, meaning any public host
allow_private_for_allowed_hosts False
allowed_content_types ("image/*", "application/pdf")
max_bytes 20 MB
timeout 10 seconds for the whole download, redirects included
allowed_gcs_buckets empty, so every gs:// URL is rejected

Set a policy when you compile the graph:

Python
from tenxgraph.storage.media import MediaFetchPolicy

app = graph.compile(
    checkpointer=checkpointer,
    media_policy=MediaFetchPolicy(
        allowed_hosts=("cdn.example.com", "*.example.org"),
        allowed_content_types=("image/*", "application/pdf", "audio/*"),
    ),
)

allow_private_for_allowed_hosts is for on-premises setups where a listed host really lives on a private address. Cloud metadata addresses stay blocked even then. Without a policy, the default one applies.

On the Google GenAI path, a refused or failed download does not fail the run. It becomes a [Failed to load image], [Failed to load audio] or [Failed to load media] text part, and the warning logs the URL without its query string, since signed URLs carry secrets there.

One behaviour change to plan for: on Google graphs, audio and video links stop downloading until you allow those content types in the policy.

The server never downloads gs:// URLs itself. They are passed through to Vertex AI, which reads them with the server’s own credentials. That is why the bucket list exists: check_url rejects any gs:// bucket not in allowed_gcs_buckets, and the converters do not enforce it.

The API refuses bad URLs before the run starts

When you serve with 10xgraph-api, every external media URL in an incoming message goes through the graph’s media_policy.check_url before anything runs. The check touches no network: it looks at the enabled flag, the scheme, credentials in the URL (refused), the host allow-list and, for an IP literal, whether the address may be reached. A blocked URL raises MediaURLBlockedError and the API answers 400 with the policy’s message. The same check runs when a client puts thread state. The policy lives in core and is set with compile(media_policy=...); the API has no config for it. Names are resolved at download time, and their addresses are checked then.

So there are two layers. The API rejects URLs that are wrong on their face, cheaply and early. The download client then checks what a name actually resolves to, on every hop.

What this does not cover

  • A tool you write yourself that calls requests or httpx gets none of this. Use tenxgraph.utils.safe_http or put your own checks in front of it.
  • fetch_url returns text from a public page to the model. That text is untrusted input to the model, which is a prompt-injection question and not an SSRF one. See A prompt-injected model cannot skip your approval step for how the graph limits what a steered model can do.

Frequently asked questions

Can the model fetch http://localhost or a cloud metadata address with fetch_url?
No. Every address a name resolves to must be globally routable, and the connection goes to the exact address that was checked. Redirects are followed by hand, up to 5, and each hop is checked again.
Does fetch_url use my HTTP_PROXY settings?
No. It reads no proxy environment variables, so a proxy setting cannot route a request around the address check.
Why did my audio or video link stop loading on a Google graph?
The default MediaFetchPolicy accepts image/* and application/pdf only. Allow the audio or video content types in a policy passed to graph.compile(media_policy=...).

Written by

Shudipto Trafder

Maintainer, 10xGraph

Keep reading

Subscribe via RSS

5 min read

A prompt-injected model cannot skip your approval step

How 10xGraph keeps handoffs, tool visibility, tool results and interrupt resumes under the graph's control, so a steered model cannot walk around a human approval.

  • security
  • interrupts
  • human-in-the-loop

4 min read

Logs that are safe to ship

What 10xGraph keeps out of logs, traces and Sentry: hashed user ids, redacted Redis URLs, tool error references, escaped client values and generic errors in production.

  • observability
  • security
  • logging

Blog home Archive