SSRF in AI Tool Calls: Agentic Code and Server-Side Request Forgery
When an LLM returns a URL and the application fetches it without checking, the attacker controls the destination. Here's what that looks like in agentic code, what BrassCoders can catch, and what the AI triage layer handles.
The flaw is a one-liner. An LLM framework receives a tool call result containing a URL, passes it directly to requests.get(), and returns the response to the model. The application treats the URL as trustworthy because the model produced it. The attacker treats it as an open door.
Server-Side Request Forgery — OWASP A10:2021 — is one of the oldest web vulnerabilities. What agentic AI code does is put it in a new context: the URL no longer needs to come from a user form field. It can come from the model itself, after an attacker influences what the model says.
What Tool Calls Look Like in LLM Application Code
BrassCoders sees this pattern often in agentic codebases: a tool dispatch loop that hands model-returned parameters directly to I/O functions.
The structure is the same across frameworks. The model returns a JSON object with a tool name and arguments. Application code parses those arguments and calls the corresponding function. In an HTTP-fetching tool, that means the model’s argument lands in the URL position:
def execute_tool(tool_name: str, args: dict):
if tool_name == "fetch_resource":
url = args["url"] # model-returned value
response = requests.get(url) # no validation
return response.text
SWE-agent and OpenHands — the reference implementations tracked in the BrassCoders Research Index, Category 12: Agentic AI Code Security — both make HTTP tool calls as part of their agent loops. The pattern is not a niche implementation detail. It is how agentic systems work.
The tool loop has an implicit assumption baked in: the model returns sane arguments. That assumption is the vulnerability.
The SSRF Risk When the Model Controls the URL
BrassCoders treats this as a structural gap between what the model produces and what the application verifies before acting.
Standard SSRF happens when user input reaches an HTTP call. The attacker sends ?url=http://internal-service/admin in a request parameter, and the server fetches it on their behalf. Frameworks handle this at the web layer — Flask’s request.args, Django’s request.GET, Next.js’s req.query. Taint analysis can follow that flow.
Agentic SSRF is different. The URL comes from a language model response, not from a user form field. An attacker who can influence model output — through prompt injection in retrieved documents, poisoned tool results, crafted system prompt context, or adversarial user messages — can steer the model to return a URL of their choosing. The application then fetches it.
The risk profile is concrete. A URL of http://169.254.169.254/latest/meta-data/ returns AWS instance metadata, including IAM role credentials, from inside an EC2 environment. A URL of http://10.0.1.5:8080/admin reaches an internal service that assumes it isn’t reachable from outside the VPC. Neither requires the attacker to break authentication. They just need to influence what URL the model outputs.
Cloud Metadata: The High-Value Target
BrassCoders flags SSRF findings against the pattern that most commonly leads to credential theft: user-controlled or model-returned URLs reaching outbound HTTP calls. The AWS Instance Metadata Service endpoint — http://169.254.169.254/latest/meta-data/ — is publicly documented and is the canonical SSRF target in cloud environments.
A successful fetch to /iam/security-credentials/ returns a JSON object with an access key ID, a secret access key, and a session token. Those credentials work until the IAM role rotates them.
IMDSv2 (Instance Metadata Service version 2) adds a required PUT request to obtain a session token before fetching metadata, which blocks the naive single-request attack. It is not enabled by default on older EC2 instances. Many codebases run on infrastructure where the person who launched the instance did not configure IMDSv2. Assume the endpoint is reachable unless you have confirmed otherwise.
GCP and Azure have equivalent endpoints on different addresses. Internal services present the same exposure at any private IP range. The cloud metadata endpoint gets most of the coverage in security writeups because it produces credentials directly. The internal service attack surface is larger.
What BrassCoders Can Catch
BrassCoders’s Semgrep taint rules — brass.python.taint.ssrf and brass.javascript.taint.ssrf — trace user-controlled HTTP input to outbound URL positions. The Python rule sources from Flask’s request.args, Django’s HttpRequest parameters, and FastAPI’s Query(), Path(), and Body() dependency-injected values. The JavaScript rule sources from Express’s req.query, req.body, req.params, and req.headers. Both rules fire when that data reaches requests.get(), httpx.get(), fetch(), axios.get(), and the Node.js http.get() / https.get() functions, among others.
The Pysa interprocedural taint scanner adds a second layer for Python: where Semgrep follows taint within a file or function scope, Pysa follows it across function call boundaries. An SSRF where the user-supplied URL passes through two helper functions before reaching the HTTP client can evade file-level analysis. Pysa’s data-flow model handles that case.
The coverage boundary: Bandit does not have a dedicated SSRF rule. BrassCoders runs Bandit as one of its 12 scanners, and Bandit will not surface SSRF from HTTP request construction. The Semgrep and Pysa rules fill that gap for the cases they reach.
What they don’t reach is the model-returned URL. When the URL comes from tool_args["url"] or json.loads(model_response)["url"], that variable has no taint annotation. The static rules see a local variable being passed to requests.get(). No alarm fires. The structural pattern — model output passed directly to an I/O function — is exactly the kind of context the AI triage layer reads, not something the pattern scanner infers.
What the AI Triage Layer Handles
BrassCoders surfaces structural findings — the scan result says an HTTP call exists near a potential taint source. The AI assistant consuming that YAML reads the finding alongside the surrounding code and answers the questions the static scanner cannot: where does the URL come from, is there any validation between model output and the fetch, and does the function sit inside a tool dispatch loop?
That context is what static taint analysis cannot carry across the model-output boundary. BrassCoders reports what it can confirm structurally; the AI triage layer evaluates the finding in context and determines whether the specific URL source is validated, trust-anchored, or open.
The division is intentional. BrassCoders is the dumb-but-accurate pattern reporter. The AI assistant is the smart triage layer. Neither should cross into the other’s role: if BrassCoders started demoting SSRF findings because a variable name looked like it came from a config file, it would be inferring context it cannot verify, and real bugs would get silently promoted to “probably fine.”
The Fix: Allowlisting Before the Request
BrassCoders can flag the HTTP call; the allowlist is what stops the request from reaching a bad destination. The correct defense is an allowlist function that every outbound URL passes through, regardless of origin.
ALLOWED_HOSTS = {"api.example.com", "data.example.com"}
def validated_fetch(url: str) -> requests.Response:
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https":
raise ValueError(f"Disallowed scheme: {parsed.scheme}")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError(f"Host not in allowlist: {parsed.hostname}")
return requests.get(url, timeout=10)
Route all model-initiated HTTP calls through this function, not through the HTTP client directly. The allowlist gates on hostname, not IP, to avoid the mapping problem (different hostnames can resolve to the same private IP). Add a private-range blocker as a defense-in-depth layer — reject requests where the resolved IP falls in RFC 1918 ranges — but treat it as a second line, not the primary control.
One common implementation mistake: blocking specific hosts rather than allowlisting permitted ones. A blocklist that includes 169.254.169.254 does not block the same metadata endpoint accessed via a redirect, via an alias, or on a different cloud provider’s address. Allowlists fail closed. Blocklists fail open.
For agentic code specifically, the proxy function pattern also creates a natural audit point. Every model-initiated HTTP call flows through one place. Log the URL before fetching. Add rate limiting. Add the private-range check. The tool dispatch loop stays clean; the security logic lives in one function you can test in isolation.
BrassCoders 2.0.10 is available on PyPI. Install with pip install brasscoders. The OSS core is Apache 2.0 licensed and requires no account. Paid enrichment — the Semgrep and Pysa taint analysis passes that surface SSRF and other data-flow findings — is available at $12/month per license at coppersun.dev/pricing.
Frequently Asked Questions
What is SSRF in LLM tool-calling code?
Server-Side Request Forgery in LLM tool-calling code happens when a model returns a URL as part of its response and the application fetches it without checking the destination. The attacker's path to exploitation is indirect — rather than sending a malicious URL through user input, they influence model output (through prompt injection, crafted context, or poisoned tool results) and the application makes the dangerous request on the model's behalf.
Can BrassCoders catch SSRF?
BrassCoders catches SSRF when user-controlled input — HTTP request parameters, query strings, form fields, or request bodies — flows into an HTTP fetch call without validation. The bundled Semgrep taint rules (brass.python.taint.ssrf, brass.javascript.taint.ssrf) and the Pysa interprocedural analysis trace this data flow. The coverage gap is model-returned URLs: when the URL comes from a parsed JSON response or tool call result, that variable is not tagged as a taint source, so the static rules don't fire. The AI triage layer — the Claude or Cursor session consuming BrassCoders YAML — handles that class by reading context.
Why does SSRF matter more for agentic AI code?
Agentic AI systems — frameworks where an LLM decides what HTTP calls to make and executes them automatically — run in environments with broader network permissions than a web endpoint. A web handler makes one request per user action. An agent makes a sequence of tool calls autonomously. If any step in that sequence delivers a malicious URL to the fetch function, the agent executes it with its full network scope, which often includes internal services and cloud metadata endpoints, before a human reviews the result.
What's the fix for SSRF in tool-calling code?
The primary defense is an allowlist of permitted hostnames, applied before every outbound request regardless of where the URL came from. Allowlists are safer than blocklists because they fail closed: a blocklist that misses one private range leaks; an allowlist that misses a legitimate host returns an error. Supplement with scheme validation (https only), port validation (if the tool only needs 443, reject everything else), and a private-range blocker as a defense-in-depth layer. For agentic code specifically, route all model-initiated HTTP calls through a thin proxy function that enforces these checks before passing the URL to the real HTTP client.
Does blocking 169.254.169.254 solve SSRF?
No. Blocking the AWS metadata endpoint by IP address helps, but it is one address on one cloud provider. EC2 Instance Metadata Service v1 also responds on internal hostnames some configurations resolve. GCP and Azure use different metadata IPs. Internal services — databases, admin panels, internal APIs — are on 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16, none of which are captured by blocking a single IP. An allowlist that only permits the hostnames your tool actually needs is the correct control.