13 min read

L5 · Security

The code works for every honest user. Now read it as someone who wants what isn't theirs.


The story

In 2019 the title-insurance company First American Financial had a document viewer for customers. A customer received a link to their own mortgage documents, with a number in the URL. Changing that number showed someone else's documents: bank account numbers, tax records, driving licences. Reports at the time put the number of documents reachable this way at around 885 million, going back about 16 years.

There was no clever exploit. No injection, no stolen password. The server checked that a document existed, and never checked that it belonged to the person asking.

That's the most common serious security bug in web applications, and it's invisible to every test that uses honest inputs. Security review is a change of viewpoint: stop asking "does this work?" and start asking "what happens if I send something I'm not supposed to?"


Part 1: Three questions for every input

List every input that crosses a trust boundary: path params, query strings, bodies, headers, cookies, uploaded files, webhook payloads, URLs the server fetches, even data you stored earlier from a user. Then ask three questions of each one:

  1. Who is allowed? Authentication says who you are. Authorization says what you may touch. Most serious bugs are a missing authorization check on one route.
  2. Where does it end up? Every place where data becomes code (SQL, shell commands, HTML, file paths, regex, eval) needs the data kept separate from the code.
  3. How much? Bodies, uploads, arrays, guesses per minute: anything unbounded is a denial-of-service or brute-force opportunity.

Part 2: Authorization

Check authorization on the server, for the specific object, on every request:

WrongRight
"The button is hidden for non-admins"The route checks the role
"The user is an admin"The user is an admin of this workspace
"The order exists"The order exists and belongs to the caller
update(id, req.body)Update only an allow-listed set of fields

Drill 1: Who's allowed?

Bug hunt
Documents and members
4 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Part 3: Injection — keep data out of code

Every injection bug has the same shape: user data is pasted into something that will be interpreted as code. The fix always has the same shape too: pass the data through a channel that can't be interpreted.

Where it landsPasted (vulnerable)Separated (safe)
SQL values`WHERE id = ${id}`WHERE id = $1 with parameters
SQL identifiers (ORDER BY, columns)`ORDER BY ${sort}`An allow-list mapping to known columns
Shellexec(`convert ${file}`)execFile('convert', [file]), no shell
File pathspath.join(dir, name)Resolve, then check it's still inside dir
HTMLinnerHTML = texttextContent, or a sanitiser
Mongo filtersfind({ email: body.email })Validate email is a string
JavaScriptnew Function(expr)A restricted expression parser

Drill 2: Injection

Bug hunt
Search and files
5 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Part 4: The browser

Browser-side attacks use the victim's own logged-in browser against them:

  • XSS: the attacker's script runs on your page, with the user's session.
  • CSRF: another site makes the user's browser send a request to yours, with their cookies.
  • Open redirect: your trusted domain forwards users to the attacker's page.
  • CORS misconfiguration: you tell browsers that any site may read your API's responses as the user.

Drill 3: Browser-facing code

Bug hunt
Comments and login
5 bugs planted across 3 files. Click every line you think is wrong, then check.
0:00 / 5 min

Part 5: Tokens, secrets and abuse

Tokens must be unguessable, verified, expiring and, where relevant, single-use. Secrets must never reach logs, the client bundle or the repository. Abuse controls (rate limits, size limits, generic error messages) stop attackers from simply trying until something works.

Drill 4: Tokens

Bug hunt
Auth tokens
5 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Drill 5: Abuse

Bug hunt
Login and OTP
4 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Drill 6: Fetching and uploads

Bug hunt
Avatar import and uploads
4 bugs planted. Click every line you think is wrong, then check.
0:00 / 4 min

Drill 7: Secrets and leaks

Bug hunt
Logging, errors and keys
4 bugs planted across 3 files. Click every line you think is wrong, then check.
0:00 / 4 min

Drill 8: Subtle code execution and DoS

Bug hunt
Merge, regex and formulas
3 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Cheat sheet

SignalRiskFix
:id loaded without the caller in the queryIDORScope every lookup to the caller or tenant
Role check without the resourceCross-tenant accessCheck role for this resource
update(…, req.body)Mass assignmentAllow-list fields with a schema
${…} inside SQL, shell, pathsInjectionParameters, execFile, resolve-and-check
ORDER BY ${…}Identifier injectionAllow-list mapping
innerHTML, dangerouslySetInnerHTMLXSSText, or a sanitiser
res.redirect(req.query…)Open redirectRelative, same-site paths only
Origin echoed + credentialsCORS bypassExplicit origin allow-list
Cookies without flagsTheft, CSRFhttpOnly, secure, sameSite
jwt.decode, no exp, Math.random tokensForged or guessable tokensverify, expiry, crypto.randomBytes
=== on signaturesTiming leaktimingSafeEqual
No limit on login/OTP, trusting X-Forwarded-ForBrute forceRate limits on the real client IP, attempt caps
Different messages for "no user" / "wrong password"EnumerationOne generic message
fetch(userUrl)SSRFAllow-list hosts, block private ranges
Client mimetype / filenameMalicious uploadsCheck bytes, generate names, serve as attachment
Logging headers/bodies, stack traces in responses, NEXT_PUBLIC_* secretsLeaksRedact, generic errors, server-only secrets
Deep merge of user JSON, nested regex quantifiers, new FunctionPollution, ReDoS, RCESchema validation, safe regex, no eval

Say it in the interview

"The download route loads the document by ID and never checks it belongs to the caller, and the IDs are sequential, so any logged-in user can download every customer's documents by counting. I'd rank that first. The fix is to scope the query by owner and return 404 otherwise; the prevention is a data-access layer where every lookup takes the caller, plus a test that tries another user's ID on every route."


Go deeper