Skip to content
Go back

I Pointed A Security Scanner At My Own Blog And It Found Exactly One Thing 🔍

There is a specific flavour of embarrassment reserved for pointing a security scanner at a website you wrote yourself.

You already know the shape of the result before it lands. It will find nothing much. You will feel briefly competent. You will close the tab and go and do something harder. That is the deal you have mentally signed before you click run.

Mine found one thing.

Then it found the same thing three more times after I had fixed it, and that turned out to be the more interesting half of the story.

What was actually scanned

124 files in the repo. Four scanners in theory: static analysis, secrets, open-source dependencies, infrastructure-as-code.

Open-source dependencies came back empty — [], no vulnerable packages. That one I am genuinely pleased with, because a static blog accumulates dependencies the way a gutter accumulates leaves. Nobody prunes them, and the next thing you know something you pulled in for a sitemap is two majors behind and quietly fetching things you never asked for.

Secrets: nothing. Good, because I have certainly published enough plain text over the years to have earned a hit.

Static analysis: one finding.

Out of everything on this site — the publish pipeline, the header logic, the RSS generation, the search index, the image renderer — exactly one line made it through.

The line

AIK_js_ssrf · severity 60
src/utils/loadGoogleFont.ts:23:27-38
const res = await fetch(resource[1]);

Server-side request forgery, in code that renders my open-graph images.

Here is what that code is doing. Every post gets a 1200×630 preview image generated at build time, one per post, before anything is drawn. To put real text on them I need the site’s actual font rather than whatever fallback the renderer would pick, so the build goes and fetches the font before it draws anything.

That takes two fetches.

The first one I build by hand. The URL is a template literal I wrote, every character of it mine to choose: fonts.googleapis.com/css2?family=…. Nothing to argue about there.

Google answers with a stylesheet, and the stylesheet says where the actual font file lives — a src: url(…) pointing at a second host. A regular expression pulls that URL back out of the response text.

The second fetch takes that extracted URL and requests it.

const resource = css.match(/src: url\((.+?)\) format\('(opentype|truetype)'\)/);
const res = await fetch(resource[1]);

The target of fetch #2 came out of a response body I did not author.

That is the whole finding, and the scanner is right to raise it. I do not get to pretend otherwise just because I trust Google, because “I trust the party upstream of the request” is exactly the sentence people say right before they explain how their build started reaching a link-local metadata address.

Diagram explaining where a build-time request forgery sits in a static site's publish chain. The top row shows six stages left to right: your laptop, marked as having no network risk; git push, marked as the only trigger; a CI build machine highlighted in orange and labelled has network egress and holds your secrets; the dist output of static files; the CDN edge that serves the reader; and the reader arriving last. A caption underneath notes that only one box in the chain can be talked into making a request on its own, and it is the one in orange. The bottom panel zooms into src utils loadGoogleFont with the subtitle two fetches, one trust decision. The first node is fetch one, pointing at fonts googleapis com css2, labelled as safe because the URL is built by us. The second node is the response, described as CSS text with a regular expression pulling a url out of it. The third node is a highlighted orange box for fetch two taking resource 1, labelled as the hop because the target came out of somebody else's response body. The final box in cyan is the fix: if the extracted URL does not start with the fonts gstatic com prefix, throw.

Being honest about the severity

If I stop at “SSRF on line 23” I have written a scary paragraph that is also a fairly useless one, because the honest version has a few more clauses in it.

To actually move that second fetch somewhere interesting, something would have to already be controlling what fonts.googleapis.com returns over HTTPS. That means either breaking TLS to Google or redirecting Google’s DNS from the build machine. On a laptop that is a network adversary sitting on your connection already — and if they are there, the font loader is the least of your day.

So: not exploitable as it stood. A scanner calling this urgent would be overselling it.

But a scanner saying “this line deserves a second look” is saying something true. The shape of the code is wrong regardless of who currently benefits from it. The request target is parsed out of external content, and it is one careless refactor — a different stylesheet source, a configurable font URL, someone pointing it at a custom provider — away from being a real pivot. The exploitability is a property of the inputs. The pattern is a property of the code. Only one of those is under my control today.

Why build-time changes the argument

There is a version of this fetch that nobody would blink at: it running on my own machine, while I watch it, failing loudly in front of me.

This one does not run on my machine. It runs on the build box.

That is the difference that tipped it from “could fix” to “will fix”. The CI runner has network egress by design — that is how it installs dependencies. It holds build secrets. It runs unattended on every push, which means it runs when I am not looking, which is the exact condition under which an outbound request to an unexpected place stops being an anecdote and starts being a log entry I would rather not be reading later.

server-side in the finding name is doing real work. The severity is not about what it reaches. It is about where the request originates from.

The fix — twelve lines and a refusal

// The URL is parsed out of the stylesheet response rather than built by us,
// so pin the origin before fetching it. OG generation runs during the build,
// on CI, with the build environment's network in reach — anything that is
// not Google's font CDN is worth failing loudly over rather than following.
const fontOrigin = "https://fonts.gstatic.com/";
if (!resource[1].startsWith(fontOrigin)) {
  throw new Error(
    `Unexpected font origin: ${new URL(resource[1]).origin}`
  );
}

Two decisions in there that are worth naming.

Fail closed. If the URL is unexpected I do not fall back, do not retry, do not substitute a local font and carry on. The build stops. A build that refuses to fetch is strictly cheaper than one that fetches the wrong thing — a broken OG image is visible to me in about four minutes, whereas a request made to the wrong host may not be visible to anyone ever.

Prefix, not equality. fonts.gstatic.com serves every one of these font files, but the path varies by subset, format and cache-busting token. Pinning the scheme and the host, then allowing the rest of the path, is the tightest check that does not break the thing it is protecting.

Then I tested it, because a security check you have not tried to defeat is a comment:

#InputExpected
1https://fonts.gstatic.com/s/roboto/v30/font.woff2pass
2https://fonts.gstatic.com.evil.com/xthrow
3http://fonts.gstatic.com/x (downgrade)throw
4https://notgoogle.com/?x=fonts.gstatic.com/throw
5https://evil.com/fonts.gstatic.com/throw
6https://fonts.gstatic.com@evil.com/xthrow
7https://169.254.169.254/latest/meta-data/throw

Seven out of seven. Cases 2 and 6 are the two that catch people — a suffix pretending to be a host, and a userinfo block hiding the real one — and both are handled by the same thing: comparing from position zero, so nothing that comes before the prefix can be used to launder what comes after.

Then the build: 27 of 27 open-graph PNGs regenerated, and og.png byte-identical to the version before the fix. Which is the result I actually wanted — the check got tighter and the output did not move.

Then it kept finding it

This is the part I did not expect.

I fixed it. Re-ran the scan. AIK_js_ssrf, same severity — now reported against the line where I did the check.

I rewrote the check. Re-ran. Reported against the line where I did the next check.

It never got a fourth attempt because I stopped writing fixes to satisfy it.

What is happening is that taint tracking does not treat a string comparison as a taint barrier. The variable still originates from the untrusted response body, and a startsWith() does not change its provenance — it only constrains its use. From the scanner’s side of the ledger, resource[1] is still tainted all the way down to the fetch.

That is not the scanner being stupid. It is a genuinely hard problem: deciding which runtime guards are sound enough to prove safe is undecidable in general, so scanners reason conservatively and assume you might have written the guard wrong.

The awkward part is the practical consequence. There is no way to make this specific finding disappear that is not either worse code or a suppression I would then have to justify to myself every time I saw it. The fix is right and the badge stays red.

And I cannot even file the “it is a false positive, please adjust” ticket — aikido_issues_list returns 400 — Feature is disabled for workspace BitzyLabs, so the suppression path needs a permission toggle I do not hold.

So the finding sits there, permanently, and every time I open the dashboard it makes me re-derive that it is fine. Which is a tax on attention that a green tick would have saved.

The gap that mattered more

While chasing that, I went looking at the other three scanners.

Infrastructure-as-code had not run.

Not “ran and found nothing” — never ran. The same message on the full repo scan and on a targeted scan: Checkov is not configured correctly. Silently, both times, alongside a dashboard that otherwise reported coverage.

Which means the scan I had been reading as “four engines, results in” was closer to “two engines ran, one found something, one never started”.

I only noticed because I was already suspicious about the dependencies result being clean. I had deleted two Docker files the day before — not because a scanner told me to, but because I read them, saw they referenced a dev command in a package tree that would never run, and removed them by hand. So when the IaC column came up empty I already knew there had been something in it to find.

Had I not done that, I would have believed the column.

That is the failure worth writing down. Not the SSRF. Not even the false positive.

A scanner that reports a false positive costs you the time you spend disproving it. A scanner that cannot scan but looks like it did costs you the coverage you did not know you had lost — and unlike the false positive, nothing in the interface tells you to go and check.

I would rather have a tool that screams at me than one that shrugs politely while doing nothing.

What I would tell someone setting this up

Run it, absolutely — the dependency result was genuinely clean and the one SAST finding was a real pattern worth closing, even if it was not exploitable today. Neither of those would have surfaced from reading the code, because I was reading it for the thing I thought was interesting.

But do not treat an empty column as a result until you have seen it produce a non-empty one. Seed a known-bad string, point it at a file you deliberately break, force it to find something — and if it will not, treat its silence as unproven rather than as good news.

Coverage you have not verified is not coverage. It is a dashboard.


The scanner was right about the line and wrong about what my options were — I could not make it see the fix, only make the fix correct. Which, now that I write it out, is most of my relationship with tooling: the part that reports, and the part that has to decide what to do with the report, are still two different things.

What’s the check you know is running but have never once seen produce a finding — and what would it take to prove it isn’t just quietly doing nothing? 👇


Share this post on:

Previous Post
From A Markdown File To A Reader's Screen: Everything That Happens When I Press Push 🚦
Next Post
Four Scanners Looked At My Blog From A Fedora Terminal — Three Were Telling The Truth 🔍