The methodology¶
Most reviews start by reading a diff and hoping something looks wrong. This one starts by deciding what the change should protect, then checks whether the code does it. Threats you never named are threats you won't notice.
The methodology at a glance¶
-
πΊοΈ Understand the scope and architecture What it does, its roles, its tech stack, and how the pieces fit together.
-
πͺ Identify the entry points Every place user input enters: web pages, forms, backend routes.
-
π― Identify the dangerous sinks Where input could change behaviour: SQL queries, HTML/DOM, OS commands, templates.
-
π§© Build a threat model
- π Business logic β missing controls, at every entry point
- π Source to sink β injection, at every dangerous sink
-
π Review mitigations For each threat: is it mitigated in the code, and how and where?
- π₯ Exploitability β for anything not fully mitigated: who could abuse it, and what they would need
- π οΈ Suggested fix β the smallest change that closes it, and how to verify
Step 4 is the heart of it. The rest exists to make step 4 possible.
The example: one pull request¶
#128 Β· Add button to edit a task's description β alice wants to merge
edit-task into main. Four files, +32 β1.
| File | Change | Purpose |
|---|---|---|
public/index.html |
+1 | Adds the Edit button to each task |
public/tasks.js |
+12 | editTask(): prompts, sends the request, updates the page |
routes/tasks.js |
+10 | New PUT /api/tasks/:id route with an ownership check |
store/taskStore.js |
+9 β1 | New update() function and export |
A pull request is a list of files, not a story. Note what is not in the
diff: app.js, where sessions and authentication are set up. Review only the
changed lines and you will never see the control that protects them β or notice
that it is missing.
πΊοΈ Step 1 Β· Understand the scope and architecture¶
Tech stack: Node.js with Express and express-session, PostgreSQL through
pg, plain JavaScript in the browser.
Roles: the task owner, other logged-in users, anonymous visitors.
Then read the code like a story: what happens when a user clicks Edit?
%%{init: {"sequence": {"actorMargin": 70, "messageFontSize": 15, "actorFontSize": 15, "noteFontSize": 14}}}%%
sequenceDiagram
actor U as User
participant B as Browser
participant S as Backend
participant D as PostgreSQL
U->>B: Clicks Edit and types a description
B->>S: Sends the update with the session cookie
S->>S: Refuses it unless a session identifies a user
S->>D: Reads the task row
S->>S: Compares the task's owner with that user
S->>D: Writes the new description
S-->>B: Returns the updated task
B->>B: Replaces the text on the page
Each step says what happens; the code it happens in sits beneath it, tagged with where it runs β π₯οΈ front end, π network, βοΈ backend, ποΈ database.
Step 1 Β· π₯οΈ Front end β the button, and the request it builds
// public/tasks.js:23-31
async function editTask(id) {
const description = prompt('Description')
const res = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
})
const task = await res.json()
The URL is built in the browser, which makes it a sink of its own β see client-side path traversal.
Step 2 Β· π Network β what actually arrives
PUT /api/tasks/42 HTTP/1.1
Host: todo.example.com
Cookie: connect.sid=s%3AkF9x...
Content-Type: application/json
{"description": "Buy oat milk"}
Step 3 Β· βοΈ Backend β the authentication gate, outside the diff
// app.js:5-11
function requireAuth(req, res, next) {
if (!req.session.userId) return res.sendStatus(401)
next()
}
app.use('/api/tasks', requireAuth, tasksRouter)
Step 4 Β· ποΈ Database β the read, and the code that issues it
// store/taskStore.js:11-15
async function get(id) {
const { rows } = await db.query(
'SELECT * FROM tasks WHERE id = $1', [id])
return rows[0]
}
Step 5 Β· βοΈ Backend β the ownership check
// routes/tasks.js:32-35
const task = await store.get(req.params.id)
if (task?.owner_id !== req.session.userId) {
return res.sendStatus(404)
}
Step 6 Β· ποΈ Database β the write
// store/taskStore.js:19-22
const { rows } = await db.query(
`UPDATE tasks SET description = $1
WHERE id = $2 RETURNING *`,
[description, id])
Step 7 Β· π Network β what goes back
HTTP/1.1 200 OK
Content-Type: application/json
{"id": 42, "owner_id": 7, "description": "Buy oat milk"}
Step 8 Β· π₯οΈ Front end β how it reaches the page
A reviewer who cannot draw this diagram does not yet understand the change well enough to review it. A step with no control on it is the one to look at twice.
πͺ Step 2 Β· Identify the entry points¶
| # | Entry point | Authentication | Action performed | Sensitivity |
|---|---|---|---|---|
| EP1 | PUT /api/tasks/:id |
Session cookie | Rewrites one task's description | Medium β private content, one record, reversible |
For each one, record what it does in business terms, what goes in and comes out, and β the part people skip β the authorization model:
| Inputs | id (URL path) Β· description (JSON body) |
| Outputs | The updated task: id, owner_id, description |
| Authorization model | Per-record ownership: tasks.owner_id must equal the session user. No tenants, groups, roles, permissions or feature flags β ownership is the only dimension |
Then write down who should be able to do it:
- β The task's owner, in a session they started.
- β Any other logged-in user, whether or not they know the task ID.
- β Anonymous callers.
- β Another origin acting through the victim's browser and cookie.
That list is the yardstick for step 4. A business-logic vulnerability is the code allowing something marked β, or blocking something marked β .
The Edit button and prompt() collect the description, but the server cannot
trust them: any client can call EP1 directly.
π― Step 3 Β· Identify the dangerous sinks¶
| # | Sink | Type | Reached by |
|---|---|---|---|
| SK1 | db.query(UPDATE β¦) |
SQL query | description, id |
| SK2 | .textContent = β¦ |
Page update (DOM) | description, from the response |
Record the sink types you searched for and did not find, so coverage is visible rather than assumed.
π§© Step 4 Β· Build a threat model¶
Two categories, checked in different places.
π Business logic β missing controls, at every entry point¶
| # | Entry point | Threat | Expected mitigation |
|---|---|---|---|
| T1 | EP1 | Missing authentication | A logged-in session is required |
| T2 | EP1 | Missing authorization (IDOR) | Only the task's owner can edit it |
| T3 | EP1 | Missing CSRF protection | Cross-site requests can't change data |
π Source to sink β injection, at every dangerous sink¶
| # | Sink | Threat | Expected mitigation |
|---|---|---|---|
| T4 | SK2 | Cross-site scripting (XSS) | The description is inserted as text, not HTML |
| T5 | SK1 | SQL injection | The query uses parameters |
Test each threat's when is it relevant criteria against every entry point and sink, using the threat catalogue. Write down the threats that did not apply and why β a threat model is only trustworthy when the gaps were considered out loud.
π Step 5 Β· Review mitigations¶
For each threat: trace the path from entry point to sink, check the mitigations the threat page lists, and look for the control elsewhere before concluding it is missing β router middleware, a service layer, a database constraint.
| # | Threat | Status | Where the control lives |
|---|---|---|---|
| T1 | Missing authentication | β Mitigated | requireAuth on the router mount, app.js:11 |
| T2 | Missing authorization (IDOR) | β Mitigated | Ownership check before the write, routes/tasks.js:33 |
| T3 | Missing CSRF protection | β οΈ Partially mitigated | Nothing explicit; the browser's preflight is doing the work |
| T4 | Cross-site scripting | β Mitigated | .textContent, never innerHTML |
| T5 | SQL injection | β Mitigated | Parameterised query, store/taskStore.js:20 |
Status: β Mitigated Β· β οΈ Partially mitigated Β· β Not mitigated Β· β Needs verification
The subtle findings live in the gap between a check and its use β a value that passes validation and is then lowercased, trimmed, normalised, decoded or rebuilt before it is used. Each threat page lists the bypasses worth trying.
What a partial mitigation looks like¶
T3 has no CSRF token, and the session cookie has no SameSite attribute.
Cross-site requests fail today only because a PUT with a JSON body is
preflighted and the preflight is refused β protection that disappears the moment
the route accepts POST or a form body.
For anything short of β , the review adds two things.
π₯ Exploitability¶
Two or three sentences: who could do it, what they would need first, and whether the victim has to act. Never attack steps or payloads β the reader needs to know whether to worry and who from.
Not exploitable as the code stands. An attacker would need the victim logged in and visiting a page they control, and would need the route to accept a request the browser sends cross-site β today it doesn't, because a
PUTwith a JSON body is preflighted and the preflight is refused.
π οΈ Suggested fix¶
The smallest change that closes it, plus how to verify.
- app.use(session({ secret: SECRET }))
+ app.use(session({
+ secret: SECRET,
+ cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
+ }))
Verify: log in and confirm Set-Cookie includes SameSite=Lax; Secure; HttpOnly.
Write it down¶
One card per threat β verdict, evidence with file and line, and for anything unmitigated its exploitability and fix β then a single table of action items and an explicit list of what needed nothing.
See the full example report, the review above rendered exactly as the skill produces it.
Why it generalises¶
Nothing here is specific to a language or a framework. The threat model comes from the shape of the application: what gets in, where it lands, and who is allowed to do what. That is also why it can be handed to an agent β see using the skill.