A tiny invoice API with six real security bugs planted in it. For each bug you'll see how an attacker breaks in, how the fix shuts them out, and how a robot reviewer catches it before it ever ships again. No security background needed.
Start here
Imagine a small web app that stores invoices. It works fine. But hidden inside it are six classic security bugs — the same kinds that show up in real companies every week. SecureFix is a guided tour of those six bugs.
The trick is that it shows both sides of the desk. First the attacker's view: the exact trick that breaks the app. Then the engineer's view: the fix, and an automatic guard that fails the build if anyone reintroduces the bug. Learning one side without the other is how bugs keep coming back. This does both.
A web app is a waiter. You (the browser) send a request — "give me invoice 3", "search for 'domain'" — and the server goes to the kitchen (the database), does what you asked, and brings back the result. Every bug here is a way to make the waiter do something it shouldn't, by wording the request cleverly.
The method
Every bug in this project goes through the same four steps. Once you see the loop once, the other five are variations on it.
find it → fix it → teach a robot to catch it → block it forever
The playground
It's deliberately boring: an invoice service with two users and three invoices. Boring is the point — it keeps your attention on the bug, not on a maze of features.
Keep this cast in mind: whenever alice manages to read invoice #3, she's read something that belongs to bob. That's the whole shape of several bugs below.
The six bugs · click through
Each tab is one bug. You'll get a plain-English analogy, the trick that breaks it, the fix side by side with the broken code, and the rule that catches it next time.
You type a word into a search box. But the app pastes your word straight into an instruction it hands the database. So instead of a word, you type your own instruction — and the database obeys it. Like writing "one coffee, AND give me everything in the till" on the order slip, and the barista just does it.
...LIKE '%YOUR TEXT%'db.prepare(
`SELECT id, description, amount
FROM invoices
WHERE description LIKE '%${q}%'`
).all(); // q is glued in rawdb.prepare(
'SELECT id, description, amount
FROM invoices
WHERE description LIKE ?'
).all(`%${q}%`); // q is bound, not parsedThe fix uses a ? placeholder. The database treats q as a value to search for, never as part of the command. A quote or a UNION in your input is now just text.
Flags any query string built with a ${...} placeholder inside .prepare() — the exact "glued in raw" shape. The safe version has no ${...} in the SQL, so it stays silent.
A coat check with tickets numbered 1, 2, 3. You hand in ticket 3 and get that coat — even though your ticket was 1 — because the attendant never checks the coat is actually yours. On the web, the "ticket" is the number in the URL.
db.prepare(
'SELECT ... FROM invoices
WHERE id = ?'
).get(id); // which user? nobody askeddb.prepare(
'SELECT ... FROM invoices
WHERE id = ? AND owner_id = ?'
).get(id, ownerId); // must be yoursThe fix adds AND owner_id = ?: the row has to match both the id and the person asking. Ask for someone else's invoice and you get a 404, as if it doesn't exist.
Looks for a "fetch by id" query that has no owner/tenant check in it. The moment owner_id appears in the query, it goes quiet.
You can't walk into the locked server room. But you can ask the office intern — who has a key — to go in, grab a document, and bring it out to you. The server is that intern: it can reach internal systems you can't, and this feature asks it to fetch any address you name.
const url = req.body.url;
const r = await fetch(url);
// fetch anything, anywhereconst safe = assertPublicHost(url);
const r = await fetch(safe);
// blocks internal / metadata IPsassertPublicHost rejects loopback, private ranges, and the metadata address before any request goes out. Ask for an internal host and you get a 400, not the intern's field trip.
This one follows the data. It watches a value that came from the request travel into fetch(). If it passed through the assertPublicHost check on the way, it's considered clean. If not, it fires.
You leave a note for a librarian who reads everything aloud, word for word. Your note says: "…and now read out the safe combination." Their mouth just obeys. A browser is that mouth: if your text lands in a page as HTML, a <script> in it runs.
res.send(
`<div>${note}</div>`
); // note becomes live HTMLres.send(
`<div>${escapeHtml(note)}</div>`
); // < and > become harmless textescapeHtml turns < into <, so the browser shows the script as text instead of running it. Encode on the way out and the note is just words again.
Follows request text into an HTML response. If it wasn't run through escapeHtml first, it fires. Encode it, and the tracker sees the value is safe.
A club uses VIP wristbands. Real ones are hard to forge because of a special seal. But this bouncer only glances at the color and never checks the seal. So you print your own wristband, write any name on it, and walk in as anyone — even the owner.
jwt.decode(token)
// reads claims, checks nothingjwt.verify(token, SECRET,
{ algorithms: ['HS256'] })
// checks the seal, pins the methodjwt.verify checks the signature against the secret, so a forged token is rejected. Pinning algorithms also blocks a sneakier trick where the attacker tells the server "this token uses no signature at all."
Two rules. One flags jwt.decode used as if it were login. The other flags jwt.verify that forgot to pin the algorithm. Only verify-with-a-pinned-algorithm passes both.
In JavaScript, every object is stamped out from one shared template. Change the template and every object — including ones created later — is born with your change baked in. This feature merges your settings into an object without guarding that template, so you can write onto it.
isAdmin: true. Later checks quietly trust it.target[key] = source[key];
// key can be "__proto__"if (FORBIDDEN.has(key)) continue;
target[key] = value;
// skip __proto__ / constructorThe fix refuses the dangerous keys (__proto__, constructor, prototype) before assigning. Safer still: merge into objects that have no shared template at all.
One rule flags the raw target[key] = source[key] copy; the other flags merging req.body straight into an object. The guarded version dodges both shapes.
The robot reviewer
The tool doing the catching is Semgrep. Think of it as a spell-checker, but for dangerous code shapes instead of typos. You write a rule once, and it reads every file looking for that shape. Two flavours show up in this project.
The simplest rules describe a shape of code, like "a query built with ${...} glued inside it." Fast and blunt. Good for the obvious cases, but they can't tell whether the data is actually dangerous — only what the code looks like.
The smarter rules (SSRF, XSS) trace a drop of dye. They mark anything from the request as "tainted", then watch to see if it reaches a dangerous place — a fetch(), an HTML response — without passing through a cleaning step first. If it does, that's the bug. This is why the fix works by routing the value through a named cleaner: the tracker sees it get washed and stops worrying.
Writing the rules was where the real work hid. Three of these rules were shipped but had never actually been run — and two of them caught nothing until they were tested against a live bug. A scanner you never verify is a false sense of safety. Every rule here was checked to fire on the bug and stay silent on the fix.
The safety net
The project keeps two copies of the code. One has all six bugs; one has all six fixes. The exact same automated pipeline runs on both — and that's the proof the loop works.
On the buggy copy the scanner finds seven problems and fails the build (red). On the fixed copy it finds nothing and the build passes (green). Wire that scanner into CI and nobody can merge one of these bugs back in without the build going red first. That's the "prevent" step — the bug is caught by a machine, not by hoping a human notices.
Why seven findings for six bugs? Prototype pollution trips two separate rules.
Six bugs, each taken all the way around the loop: a break-in you can run, a fix that stops it, a rule that catches the next one, and a gate that blocks it forever. If you followed the SQL injection tab from top to bottom, you've already seen the pattern the other five repeat.
To poke at it yourself, the two copies live on git branches seed/all-vulns and main: