Project: attack and harden Linkstash
A full security review pass over Linkstash's real endpoints, tying every fix from this series (SQLi, XSS, IDOR, timing, rate limits) into one checklist.

Eleven lessons, eleven fixes, all against the same small app. This one puts you on the other side of the table: not learning a new attack, reviewing a codebase the way a security-minded engineer actually does it, one route at a time, asking the same handful of questions at every stop.
Maya's just been handed reference/linkstash for a pre-launch review. Here's what she checks, endpoint by endpoint, and it's the same pass you should be able to run on any small API after this series.
The review checklist
Five questions, asked at every route:
- Does user input ever reach a query as a string, instead of a bound parameter?
- Does user input ever reach the DOM without being treated as plain text?
- Does a resource lookup check ownership, not just "does this ID exist"?
- Does a failure response leak more than it needs to, through its status code or its timing?
- Is every numeric input from the client bounds-checked before it's used?
Run that list against reference/linkstash/src/app.ts, and here's what comes back for each route.
POST /auth/register and POST /auth/login
registerUser hashes with argon2.hash(password, { type: argon2.argon2id }), the deliberately slow, memory-hard choice from the password storage lesson, not a fast general-purpose hash that a leaked database could crack cheaply. Duplicate emails get caught by the database's own UNIQUE constraint on users.email, not a SELECT-then-INSERT check that a concurrent signup could slip through. And the password minimum is 12 characters with no forced character classes, because length beats complexity rules people route around with predictable patterns.
verifyUser is the one worth re-reading closely, because its fix is invisible in every test that only checks the response body:
if (!row) {
await argon2.verify(DUMMY_HASH, password); // spend the same time either way
return null;
}Both branches, real user or not, cost the same tens of milliseconds. The authentication attacks lesson covered why: a response that's fast for unknown emails and slow for known ones is an account-enumeration tool, no matter what its status code says.
GET, POST, GET /:id, DELETE /:id on /links
listLinks binds every value, userId, the search term, limit, offset, through ? placeholders. Nothing about the query is built by pasting a string together, which is the entire fix from the SQL injection lesson.
GET /links also runs every numeric query parameter through intParam, which clamps limit into 1..100 and rejects anything that isn't a whole number. Without that clamp, ?limit=-5 reads as "no limit" to SQLite and returns the entire table, the resource-exhaustion bug that costs real compute and bandwidth for one malformed query string.
GET /links/:id and DELETE /links/:id are the centerpiece of this whole review. Both collapse "doesn't exist" and "exists but isn't yours" into the identical 404:
// Not-yours and not-found deliberately return the same 404. See API.md.
if (!link || link.userId !== req.userId) {
res.status(404).json({ error: "NotFound" });
return;
}That's the IDOR fix in one line: a 403 here would confirm the resource exists, which is a leak on its own even when access is correctly denied.
Quick check
A teammate suggests changing the 404 in GET /links/:id back to a 403 when the link exists but belongs to someone else, arguing it's more accurate. What's the problem?
What's missing, on purpose
Not every fix in this series lives inside reference/linkstash/src/, because not every fix is application logic. Three of them are choices made around the code, not in it:
- Auth via
Authorization: Bearer <token>, not a session cookie, which is why CSRF isn't a live threat against this API. A cross-site form can't forge a header the browser doesn't attach automatically. - Security headers (CSP,
nosniff,frame-ancestors) aren't set anywhere inapp.tsyet. That's a real gap, and the headers lesson is the one to apply next if this were shipping today, registered at the very top of the middleware stack so it covers every response, including the 404 catch-all. - Secrets never touch a tracked file.
DATABASE_URLand any signing keys live in environment variables, not hardcoded strings, the pattern from the secrets lesson.
A code review only ever tells you what's in the code. Half the fixes in this series live in decisions that never generated a diff at all.
Run the review yourself
Pick any small API you have access to, your own side project is fine, and walk the same five questions against its routes. You'll find the pattern repeats: most vulnerabilities aren't exotic, they're a check someone meant to add and didn't, usually because the happy path already worked and nobody asked what happens when the input isn't what they expected.
Quick check
Across this whole series, what's the one habit that would have caught the most bugs before they shipped?
Where this leaves you
You've broken and fixed injection, XSS, CSRF, two separate authentication bugs, broken access control, a missing rate limit, a supply-chain incident, and a leaked-secret scenario, all against one real, small codebase. That's most of what actually shows up in production incident reports, minus the exotic stuff that makes headlines and rarely applies to the app you're actually shipping.
The next step for Linkstash isn't more security work, it's getting it in front of users at all. Why deploy to a server picks up exactly there. If you want the full map of what this series covered, the Web Security series page has all twelve lessons in order.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


