Close the injection holes for good
Continues from the last build: Access control is enforced on objects and functions, with regression tests.
Last rung you locked down access control on it. Object-level checks now confirm a caller actually owns the account they are querying, and a regression suite protects that.
What you'll build
You will walk away with a documented, reproducible SQL injection exploit against your own Ledgerline instance (including a cross-account data leak and a credential dump), and then a codebase where every SQL query is parameterized, every rendered field is safely encoded, and the export feature can no longer reach a shell. You will understand, concretely, why blocklists and escaping are not real fixes and parameterization and safe APIs are. You will leave with an automated test suite that fires all three payload classes and asserts they are neutralized, plus a Semgrep rule that flags string-built SQL by pattern, so the bug class cannot silently come back.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { Router } from 'express';
import { pool } from '../db/pool';
export const transactionsRouter = Router();
transactionsRouter.get('/api/transactions/search', async (req, res) => {
const accountId = req.session.accountId;
const q = req.query.q as string;
const sql = `SELECT id, description, amount, created_at
FROM transactions
WHERE account_id = ${accountId}
AND description ILIKE '%${q}%'
ORDER BY created_at DESC`;
const result = await pool.query(sql);
res.json(result.rows);
});
Reading this file
WHERE account_id = ${accountId}Even the internal accountId is interpolated, so a payload in q can rewrite this clause and escape the account scope entirely.description ILIKE '%${q}%'User input lands inside a quoted string literal built by concatenation, the classic injection sink.await pool.query(sql)pool.query is called with a single pre-built string, no bound parameters, so the driver cannot distinguish code from data.
The search query is built by string interpolation. Both accountId and the raw user input q become part of the SQL text itself.
That's 1 of 8 explained code blocks in this single project.
The build, milestone by milestone
- 1
Exploit the SQL injection safely and measure the blast radius
5 guided stepsYou cannot justify a fix, or explain its severity to anyone reviewing your work, without first measuring what an attacker actually gets. Seeing the cross-account leak and the credential dump with your own eyes is also what makes the difference between escaping and parameterizing land, rather than being an abstract rule you memorized.
- 2
Replace every concatenated query with bound parameters
5 guided stepsEscaping special characters and blocklisting keywords like OR, UNION, or DROP both fail because there is always another encoding, another database-specific escape sequence, or a second-order injection path (data written safely once, then reused unsafely later) that slips past a filter. Parameterized queries do not filter anything, they send the SQL text and the user's data to the database as two separate channels, so the data can never be reinterpreted as SQL no matter what characters it contains.
- 3
Find and fix the reflected XSS and command injection
5 guided stepsBoth bugs are injection in the same sense as the SQL issue: user data crosses into a context (HTML markup, a shell command line) where it is interpreted as instructions instead of plain text. The fix in both cases is the same principle as parameterized queries, use an API that keeps data and instructions in separate channels, rather than trying to sanitize the string beforehand.
- 4
Add allowlist validation at the boundary as depth, not as the fix
5 guided stepsValidation at the boundary catches obviously malformed input early, gives you a clean 400 instead of a confusing downstream error, and gives you a place to log anomalous requests for detection later. But it is not sufficient on its own: a new query added six months from now that forgets to parameterize is still vulnerable even if the request passed validation, and encoding tricks or a validation bug can let a malformed value through. The sink-level fix (parameterization, execFile, textContent) is the actual guarantee; validation is the layer that reduces how often you have to rely on it.
- 5
Prove it with a regression suite and a Semgrep rule that catches the pattern
5 guided stepsA fix without a regression test is a fix that can quietly disappear the next time someone refactors that route. A green scan is not the same as secure, Semgrep and any SAST tool only find the classes of bugs they have rules for, so pairing a behavioral test (does the payload actually get neutralized) with a pattern-matching rule (does the dangerous code shape exist at all) gives you two independent signals instead of one.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building