DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

SSRF in AI Tool Calls: Agentic Code and Server-Side Request Forgery

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)