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.
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:
| # | Input | Expected |
|---|---|---|
| 1 | https://fonts.gstatic.com/s/roboto/v30/font.woff2 | pass |
| 2 | https://fonts.gstatic.com.evil.com/x | throw |
| 3 | http://fonts.gstatic.com/x (downgrade) | throw |
| 4 | https://notgoogle.com/?x=fonts.gstatic.com/ | throw |
| 5 | https://evil.com/fonts.gstatic.com/ | throw |
| 6 | https://fonts.gstatic.com@evil.com/x | throw |
| 7 | https://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? 👇