PAUL MK

Sample deliverable

What a finding looks like when I write it.

Every consultant describes their report. This is one — the same structure a client receives, on a bug you can check out and reproduce yourself.

The subject is Kartly, my own deliberately vulnerable application, because no client's findings can ever be published and an invented example would prove nothing. The vulnerable code is on main. The remediation is the real diff on fix/idor.

KTL-2026-05High
Finding metadata
FindingKTL-2026-05
TitleOrder and message records readable by any authenticated user
SeverityHigh
CVSS v3.16.5 — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Componentserver/src/services/orderService.ts · messageService.ts
EndpointsGET /api/orders/:id · GET /api/messages/:id
StatusFixed and verified — branch fix/idor

Summary

The order and message endpoints authenticate the caller but never check that the caller owns the record being requested. Any signed-in user can read any other user's orders and private messages by substituting an identifier in the URL.

Reproduction

Two accounts, alice and bob. Signed in as alice, request an order belonging to bob:

curl -s -H "Authorization: Bearer $ALICE_TOKEN" \
  https://kartly.local/api/orders/o-bob-1

Returns 200 with bob's order: line items, totals, coupon code and delivery address. The same substitution works against /api/messages/:id, returning private conversations between two other people. Identifiers are sequential, so enumeration is a loop rather than a guess.

Reproduced by the repository's own tests, which run in both directions using the same attack code. server/tests/exploits/05-idor.test.ts expects the 200 and passes on main — proof the bug is live.server/tests/fixed/05-idor.test.ts expects a 404, fails onmain, and passes on fix/idor — proof the fix holds.

Impact

Every order and every private message in the system is readable by anyone who can register an account. That is order history, delivery addresses and direct messages for the entire user base, retrievable at the speed of a for-loop.

On the severity rating. CVSS scores this 6.5, which is Medium. I am rating itHigh, and the disagreement is deliberate: CVSS measures a single exploited instance, and this vulnerability's character is that one authenticated attacker retrieves the entire dataset in minutes. Delivery addresses alone make it a notification event under the Kenya Data Protection Act. Where my rating and the calculator's differ, the reasoning goes in the report rather than the number being quietly adjusted.

Root cause, and why a competent developer wrote it this way

This is not carelessness, and a report that implies it will not get merged. The handler does authenticate — the route is behind auth middleware, and the developer who wrote it was thinking about authentication, which the framework makes easy and visible. Object ownership is a second, separate question that no framework asks on your behalf.

The sibling function directly above it, listForUser, takes auserId and scopes correctly. The single-record fetch takes the same parameter and ignores it — the argument is even named _userId, the underscore convention for "deliberately unused." The scoping was considered and then dropped, which is the most common shape this bug takes in real code: not an absent thought, an incomplete one.

Remediation

Enforce object-level authorization in the fetch, and return 404 rather than 403 so a response cannot be used to confirm that an identifier exists.

async getById(_userId: string, orderId: string) {
const order = await orderRepo.findById(orderId);
if (!order) throw new HttpError(404, "Order not found.");
if (!order || (order as { customerId?: string }).customerId !== userId) {
throw new HttpError(404, "Order not found.");
}
return toDTO(order as OrderRow);
}

server/src/services/orderService.ts — the real diff on fix/idor. The same change applies to messageService.getById, scoped to conversation participants.

Verification

Re-run the reproduction as alice against bob's identifier. Expect 404, and confirm alice can still read her own orders — a fix that breaks the legitimate path is not a fix. The repository's fixed test asserts the 404, but it does not yet assert the second half, and it should: that is the check that catches an over-broad fix, one that closes the hole by refusing everybody.

Stopping it coming back

A finding is a snapshot. It tells you this handler was wrong on the day someone looked at it, and says nothing about the next handler, or this one after the next refactor. So the last step of the engagement is a rule that fails the build:

patterns:
- pattern-either:
- pattern: '$DB.$MODEL.findUnique({..., where: {..., id: $REQ, ...}})'
- pattern: '$DB.$MODEL.findFirst({..., where: {..., id: $REQ, ...}})'
- metavariable-regex:
metavariable: $REQ
regex: ^req\.(params|query|body)\.
- pattern-not: '$DB.$MODEL.$FN({..., where: {..., orgId: ...}})'
- pattern-not: '$DB.$MODEL.$FN({..., where: {..., tenantId: ...}})'

rules/a01-broken-access-control/unscoped-lookup-by-user-id.yaml — the two pattern-not lines were added after the rule fired on its own remediation.

The two added lines are worth explaining, because they are the reason this rule is trustworthy and the reason I do not ship a rule I have not executed. Semgrep matches object patternspartially, so where: {id} also matcheswhere: {id, orgId} — meaning the first version of this rule flagged thefixed code as vulnerable. A rule that fires on correct code gets switched off within a week, and then it protects nothing.

That is the difference between a report and an outcome. The report closes one instance. The rule closes the class, on every branch, on every push, for everyone on the team — including the developer who joins next year and never reads the report.

What you would receive

One of these per finding, ordered by severity, with a summary that a non-engineer can act on and an appendix a developer can merge from. Critical findings are sent the hour they are found rather than held for the report. Thirty days later there is a retest pass over every original finding, confirming each fix holds and did not move the problem somewhere else.

paulmk2143@gmail.comSee the engagements