Build August 20, 2026 · 6 min read

The Part of My Tool That Trusted a Stranger’s Page

My SSRF guard checked the address you typed. It never checked the addresses pulled out of the page that address returned — so the dangerous request never had to pass through the box where anyone types anything.


Two posts ago I found a part of Verilay that was asking an AI to do a lookup instead of a judgement. One post ago I found a rate limit that had never actually limited anything. This one’s about a hole that never went anywhere near the box where people type.

That last part is what makes it worth writing up. Most security advice assumes the danger arrives through the front door — a form field, a search box, a URL you paste in. This one came in through the back, carried by a page that wasn’t even mine.

What the URL scan does

Verilay can check a live app three ways: paste a GitHub link, upload a ZIP, or paste the app’s actual web address. That third one is for people who don’t have their code handy, or just want a quick look before digging further.

Give it a URL and Verilay fetches the page, the same way your browser would. Then it goes a step further — it looks for <script src="..."> tags in the HTML and fetches those too, because that’s where hardcoded API keys most often turn up in a live app. A key sitting in main.js is invisible if you only look at the HTML that loaded it.

Two fetches, then. The page, and whatever scripts it points to.

The thing I thought I had

Fetching an address someone gives you is a genuinely dangerous thing for a server to do, and I knew it going in. A server sitting on Railway isn’t sitting on the open internet the way your laptop is — it can potentially reach internal addresses that have no business being reachable from outside. Cloud metadata endpoints. Internal services with no login screen because nobody ever expected a request to reach them from where they are. Point a careless fetcher at the right internal address and it’ll happily hand back whatever’s there.

This has a name — server-side request forgery, SSRF — and I’d built a guard against it before Verilay ever went live. Reject anything that isn’t http or https. Reject the addresses everyone knows to reject: localhost, 127.0.0.1, 169.254.169.254 (the cloud metadata address, the one attackers actually go looking for), the private ranges that start 10., 172.16., 192.168..

That felt thorough. It checked out fine every time I tested it with an obviously internal address.

Where it actually had gaps

Three, once I went looking properly.

It checked the address you typed, not the address it actually talked to. 169.254.169.254 was blocked. A hostname like sneaky.example.com was not — even if that hostname’s DNS record quietly pointed straight at 169.254.169.254. The guard was reading the name on the envelope and never checking where the letter actually got delivered.

It followed redirects without looking again. A perfectly ordinary, public URL is allowed to reply “actually, go here instead” — and it’s allowed to send you somewhere else again after that. My code followed those redirects automatically, the default and the convenient thing to do, and only ever checked the first address. A safe-looking URL that redirected to an internal one would sail through, because nothing re-checked the final destination.

Both of those are real, and both are now fixed — every hostname gets resolved to an actual address before it’s trusted, and every redirect is followed by hand, one hop at a time, with each new address checked exactly like the first.

The one that actually surprised me

Here’s the third, and it’s the one I keep coming back to, because I’d built a careful guard and still walked straight past it.

Remember the two fetches — the page, then the scripts it points to? I’d put my SSRF guard on the first one. The address you type gets validated properly. Blocked ranges, resolved DNS, revalidated redirects, all of it.

But the second fetch doesn’t come from you. It comes from <script src="..."> tags sitting inside someone else’s page — a page I don’t control, that could say anything. And that second fetch was going out with none of the same checking.

It’s the difference between checking ID at the front door and then letting in anyone your guest phones up and waves through the side. The front door was solid. I’d just never asked who else the front door was quietly vouching for.

So a malicious page didn’t need to attack Verilay’s input box at all. It just needed a <script src="http://169.254.169.254/whatever"> sitting somewhere in its own HTML, and Verilay would fetch it on the page’s behalf, no user input involved at any point.

That’s what made it worth its own post rather than a line in the last one. It’s not a clever trick. It’s the second fetch nobody thought to ask “does this one need checking too?” — because the obvious, scrutinised, this-is-where-attacks-come-from spot was the first fetch, and the second one just rode along on the same trust.

The fix

One rule now, applied everywhere Verilay fetches anything: every address gets resolved, checked, and revalidated at each redirect — the page you gave it, and every script address pulled out of that page. Same function, called from both places. No side door with its own, weaker set of rules.

That’s really the whole fix. The interesting part wasn’t the code — it’s about four lines different from before. The interesting part was noticing there were two doors and I’d only ever locked one of them.

One honest limitation, since overselling a fix is its own kind of dishonesty: Verilay resolves a hostname and then makes the request. If a name’s DNS record changed in the gap between those two moments, the address it checked and the address it fetched wouldn’t be quite the same one. Closing that completely means pinning the connection to the exact address you validated, which breaks how modern web hosting actually works under the hood. For a tool reading public web apps, checking properly at every hop and not hanging around is the right trade-off — but “fixed” here means “no longer wide open,” not “provably airtight,” and I’d rather say that than let this post imply more than the code actually guarantees.

If you’re building anything that fetches URLs

Ask what “the input” actually is. I thought the input was the URL box. It was also every address extracted from whatever that URL returned — a second, invisible input I hadn’t counted as one.

Validating once isn’t validating everywhere it matters. If your server ever makes a follow-up request based on the result of an earlier request — a redirect, a linked resource, a reference pulled out of a response — that follow-up needs the exact same scrutiny as the original. It’s easy to write the careful check once, feel done, and never notice a second path exists.

Resolve before you trust a hostname. Blocking known-bad addresses is not the same as blocking known-bad destinations. A name can point anywhere, and DNS will happily tell you where, if you ask before you fetch instead of after.

The uncomfortable part, again

I keep writing these posts with the same shape: I built the careful-looking version first, felt satisfied, and the actual gap was somewhere I’d stopped looking precisely because I’d already decided that part was handled.

The front door was genuinely solid. That’s what made the side door so easy to miss — everything about the first fetch looked like the security-sensitive part, so it got all the attention, and the second fetch inherited none of it by default.

Verilay’s code is public at github.com/ekbm/verilay, including the fetch logic this post is about. If there’s a fourth door I haven’t found, I’d rather someone tell me than find out the way I found this one.

Verilay is free at verilay.dev — no account needed.
Paste in a GitHub link, a ZIP from Lovable or Replit, or your live app’s address, and it will tell you in plain English what your app is made of and what’s worth worrying about. The code is public at github.com/ekbm/verilay.