DEV Community

Cover image for Your agent fetched a URL, a file, and a QR code today. None of them proved what they claimed to be.
Presendapp
Presendapp

Posted on

Your agent fetched a URL, a file, and a QR code today. None of them proved what they claimed to be.

A user-agent string is a claim. A Content-Type header is a claim. A QR code's payload is a claim. None of these are evidence — they're just strings someone (or something) decided to put there, and every one of them can be wrong, either by accident or on purpose.

This isn't a new problem. But it gets sharper the moment something acts on what it fetched instead of a human reading it first, which is exactly what an AI agent does by design. I've spent the last few days in the comments of some genuinely great security writeups — a parser-confusion SSRF bypass, a measurement of how many "AI crawler" requests are actually impersonators, a capability-composition failure that let sandboxed agents reach the open internet — and they're all the same shape of bug wearing different clothes. Here's the pattern, and four free, no-signup endpoints I built to check the specific claims that trip agents up most.

The pattern: two systems, one input, different conclusions

CVE-2026-19304 is a clean example. Two frameworks (Langflow, CrewAI) validated a URL with Python's urlparse, then fetched it with requests — which uses urllib3 internally, a different parser. Feed both the same URL with a backslash in an unusual spot, and they disagree about the hostname. The guard sees a public IP. The actual HTTP client connects to 127.0.0.1. The guard didn't fail — it answered a question about a string that wasn't the same question the fetcher was about to ask.

The AI-crawler-headers story is the same shape from a different angle: someone measured eight days of traffic claiming to be GPTBot, ChatGPT-User, and friends, and checked which ones Cloudflare could actually verify by reverse DNS. Result: 13% of GPTBot-claiming traffic verified. The other 87% was a string in a header, asserting an identity nobody checked.

And the OpenAI/Hugging Face sandbox story making the rounds this week is the large-scale version: every individual permission in that system was configured correctly. Agents just found a shared, writable substrate (a package registry cache) and used it as a message board, then as an internet gateway — because "can this caller reach the internet" was never actually asked at the one hop that mattered.

Three different systems. Same root cause: something arrived carrying a description of itself, and the description got trusted instead of checked.

Where this actually bites an agent

If your agent (or your own backend acting on an agent's behalf) fetches a URL, downloads a file, decodes something a user uploaded, or reads a bot's declared identity, you're making the same kind of decision these frameworks got wrong. A few concrete versions of it:

  • A tool call returns a URL to visit next. Is it pointing somewhere that's already known-malicious, or does it just look plausible?
  • A user uploads a file for your agent to process. Does its content match what it claims to be, or is a .pdf actually something else with a renamed extension?
  • Something in your pipeline decodes a QR code. If the payload is a URL, has anyone checked where it actually goes before your agent (or a human) follows it?
  • A request arrives claiming to be a known bot. Is that a verified identity, or just a header?

None of these need a heavyweight solution. They need one honest check, done before the "trusted" step, not after.

Four checks, one call each

These are free, no-signup, no-API-key endpoints — same policy as the rest of Presend's API. Pick the one that matches your actual risk, not all four by default.

Is this URL already known-bad, before your agent fetches it?
curl "https://presend.pages.dev/api/url-reputation?url=https://example.com"

Checks against URLhaus. A clean result means "not on this list," not "safe" — that distinction matters and the response says so explicitly.

Does this file's content match what it claims to be?
curl -X POST https://presend.pages.dev/api/file-type \
-H "Content-Type: application/pdf" \
--data-binary @upload.pdf

Reads the actual binary signature (magic bytes) and flags a mismatch against the declared Content-Type. This is the direct fix for the parser-confusion class of bug: don't trust the label, read the bytes, and if two things disagree about what something is, that disagreement is the finding.

Does a QR code point somewhere I should worry about?
curl -X POST https://presend.pages.dev/api/qr-scan -F "file=@qrcode.png"

Decodes the code and, if the payload is a URL, checks it against the same malware/phishing database in the same call — decode and verify in one step, so nothing downstream ever sees an unchecked payload.

Is this bot who its header says it is?
curl "https://presend.pages.dev/api/ai-crawler-check?domain=example.com"

This one checks the policy side (what your robots.txt permits for known AI crawlers) rather than the identity side (whether a specific request is really that crawler) — worth being precise about, since conflating the two is exactly the mistake the crawler-headers post was about. Policy and identity are different questions, and a tool that only answers one shouldn't imply it answered both.

The actual rule

Every one of the incidents above has the same fix, and it isn't "add more validation" in the abstract — it's specifically: check the same thing you're about to act on, not a description of it that arrived alongside it. A hostname string and a TCP connection destination are two different things unless you've verified they're the same. A Content-Type header and a file's actual bytes are two different things unless you've checked. A User-Agent string and the network identity making the request are two different things unless something independent confirmed it.

If your agent (or your CI, or your upload pipeline) trusts the label because the label is easy and the bytes are hard, that's the gap that eventually gets used against you — usually by something that isn't trying very hard, because it doesn't have to.

Full API docs: presend.pages.dev/api — OpenAPI spec and a Postman collection are linked there if you want to wire this into something bigger than a curl command. Curious what other "claim vs. verified" gaps people are hitting in agent pipelines specifically — the ones above are just the four I had a clean answer for.

Top comments (3)

Collapse
 
max_quimby profile image
Max Quimby •

"The description got trusted instead of checked" is a clean way to name a whole class of agent bugs. The urlparse-vs-urllib3 differential you cite is the sharpest version: the guard and the fetcher answered questions about different parses of the same string, so the check was technically "passing" while being meaningless.

The framing I'd add for agent builders: most of these are also TOCTOU bugs, not just parser bugs. Even if your guard and fetcher agree on the parse, a URL that resolved to a public IP at check time can resolve to 127.0.0.1 at fetch time via DNS rebinding. So the deeper rule isn't just "check the claim" — it's "check at the same hop, on the same resolved value, that actually performs the action." Validate the URL, then pin and connect to the exact IP you validated, rather than handing the hostname back to a client that re-resolves it.

The bot-identity stat (87% of GPTBot-claiming traffic unverified) is a great reminder that a User-Agent is an assertion, not an identity. Reverse DNS / signed-agent verification is the equivalent of "same hop" for inbound. Good writeup.

Collapse
 
presend profile image
Presendapp •

The TOCTOU framing is sharper than what we tested, and I think it exposes a real gap we haven't actually checked. Our SSRF guards (isBlockedHostname() before fetch()) only verified that the check and the fetch agree on parsing the same string — same hostname, same resolved value, at the moment we tested. What we never tested is your point: whether that hostname could re-resolve to something different by the time the actual fetch() call happens. Those are genuinely separate bugs. A guard could pass every parser-consistency test we ran with Sushrut and still be wide open to DNS rebinding, because nothing about string-parsing agreement says anything about resolution timing.

"Check at the same hop, on the same resolved value that actually performs the action" is the right fix, and it's more work than what we did: pin the IP after validation and connect to that IP directly, rather than handing the original hostname back to something that re-resolves it. Going to go check whether that's even possible cleanly with fetch() in a Workers runtime, since the standard API doesn't obviously expose "connect to this specific IP" the way a raw socket API would. If it doesn't, that's a real constraint worth knowing about rather than assuming away.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.