The IDOR Fix Cursor Writes Stops at the GET Route (CWE-639)
Ask an AI editor to fix an IDOR and it scopes the GET route to the current user, correctly, then leaves PATCH, DELETE and the list endpoint on primary-key lookups. The unscoped list endpoint is the worse half.
SafeWeave Team
TL;DR
- Ask an AI editor to fix an IDOR and it will scope the one route you showed it, correctly, then leave PATCH, DELETE and the list endpoint sitting on primary-key lookups.
- The unscoped list endpoint is the worse half. IDOR normally costs an attacker some ID enumeration, and a list endpoint that returns everyone's records hands those IDs over for free.
- The fix that holds is structural: one owner-scoped accessor that every handler is forced through, so a new route cannot be written without the check.
I asked Cursor to fix an IDOR last week. It did, and the fix was correct.
Then I scrolled down.
The handler it fixed was GET /api/invoices/:id. Twenty lines below, DELETE /api/invoices/:id was still calling findByIdAndDelete(req.params.id). Above both of them, GET /api/invoices was returning every invoice in the database to whoever asked.
The fix was real. It just had a blast radius of one route.
The Fix It Writes
The fix an AI editor writes for an IDOR is correct, and it is correct for exactly one handler.
Here is what I gave it:
// Before - CWE-639: authorization bypass through user-controlled key
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});
Here is what came back:
// After
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
userId: req.user.id, // ownership is now part of the query
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
I want to be fair to it: that is the right shape. The ownership condition moved into the query, so the database proves ownership instead of the handler proving existence. The miss returns 404 rather than 403, so the error code does not confirm that someone else's invoice exists. If you were grading this one handler, it passes.
Here is the rest of the same file, untouched:
app.get('/api/invoices', requireAuth, async (req, res) => {
res.json(await Invoice.find({})); // every user's invoices
});
app.patch('/api/invoices/:id', requireAuth, async (req, res) => {
const updated = await Invoice.findByIdAndUpdate(
req.params.id, req.body, { new: true }
);
res.json(updated);
});
app.delete('/api/invoices/:id', requireAuth, async (req, res) => {
await Invoice.findByIdAndDelete(req.params.id);
res.status(204).end();
});
Same resource. Same ownership rule. Three handlers that never heard about it.
Catch these vulnerabilities automatically with SafeWeave
SafeWeave runs 8 security scanners in parallel — SAST, secrets, dependencies, IaC, containers, DAST, license, and posture — right inside your AI editor. One command, zero config.
Start Scanning FreeWhy This Leaves You Worse Off Than a Clean Miss
The unscoped list endpoint is the dangerous one, because it removes the only real cost of exploiting the unscoped delete.
An IDOR on a write route is not usually a one-request exploit. The attacker has to get valid IDs first, and how hard that is depends entirely on your ID scheme. Sequential integers cost nothing. Random UUIDs cost a lot. You can argue about where a Mongo ObjectId falls on that line.
You do not have to argue about it when GET /api/invoices returns them.
GET /api/invoices -> 200, every invoice in the system, each with its _id
DELETE /api/invoices/<any_id> -> 204
Two requests. The first one is the enumeration step, served by your own API, authenticated, logged as a normal read. The second one is the damage. Nothing in that sequence looks like an attack in your access logs.
And the fix made the situation worse in one specific way that has nothing to do with code. There is now a commit in the history that says the IDOR on invoices is fixed. The next person who greps for findById in this file finds one clean, correctly scoped handler at the top and stops reading.
Why the Fix Stops Where It Does
The editor patched the code in its context window, and ownership is not a property of a snippet.
That is the whole mechanism. You pasted a handler and asked what was wrong with it. The model answered the question you asked, about the code you showed it, and the answer was right. But authorization is a property of the resource, not of any single route, and it is only enforced if every path that touches that resource carries the same predicate. The model's unit of work is the block of code in front of it. The unit of work for an ownership rule is all four handlers, plus the two you will write next month, plus the background job.
Those two units do not line up, and nothing in the interaction tells you they do not. You get a confident, correct-looking diff for the thing you pointed at.
This is not an AI-specific failure, which is the part worth sitting with. Humans ship it constantly. CVE-2026-47418, disclosed in June 2026 against praisonai-platform at CVSS 8.1, is exactly this shape and there is not an AI editor anywhere in the story. The project routes at GET / PATCH / DELETE /workspaces/{workspace_id}/projects/{project_id} do check something: they gate on require_workspace_member(workspace_id). Then they resolve the object through ProjectService.get(project_id), which is session.get(Project, project_id), a primary-key lookup with no workspace_id predicate anywhere in it. A member of any workspace can read, modify or delete projects belonging to a different workspace.
Two details in that advisory are the reason I am using it here rather than a cleaner one.
First: update and delete call self.get(project_id) first, and inherit the gap. One unscoped read function quietly became three unscoped operations. That is what "fix the read path" buys you when the write paths route through it.
Second: it happened three times in one codebase. CVE-2026-47415 is the same bug on issues, CVE-2026-47419 the same bug on agents. The advisory says so directly, calling the root cause identical to the agent and issue IDORs in this codebase. Three CVE IDs, one structural mistake, repeated per resource by a team that clearly understood the concept of a membership check, because they wrote one.
So the AI is not doing something uniquely stupid. It is reproducing the most common authorization mistake in web development, at the speed you can generate routes, which is much faster than you can review them.
The Framework Will Not Close the Gap Either
In Django REST Framework, the two mechanisms that can enforce ownership cover different sets of actions, and neither covers all of them.
This is documented, not folklore. DRF's own permissions guide is explicit that generic views only check object-level permissions for views that retrieve a single model instance, and that object-level filtering of list views is your job. It also notes that because get_object() is never called on create, has_object_permission() does not run there at all.
Laid out against the actions:
has_object_permission()covers retrieve, update and destroy. It does not cover list. It does not cover create.get_queryset()covers list, retrieve, update and destroy. It does not cover create.
Now guess which one an AI editor reaches for when you paste a detail view and say "fix this IDOR."
It writes the permission class. Of course it does. You said the word IDOR, the fix is an authorization fix, and the object in DRF with authorization in the name is has_object_permission. It is the more security-shaped answer. It is also the one with the list-endpoint hole.
# What you get asked for an IDOR fix
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all() # <- list still returns everything
permission_classes = [IsAuthenticated, IsOwner]
Retrieve, update and destroy are now genuinely safe. GET /invoices/ still returns every invoice in the table, because that queryset is unfiltered and the permission class was never consulted per row.
The Fix That Holds
Put ownership in one accessor that every handler is forced through, so a route cannot be written without it.
The point is not the line of code. It is that there stops being a place to forget it.
In DRF that means scoping the queryset, because it is the only one of the two mechanisms that reaches the list action, and then handling create separately:
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
def get_queryset(self):
# covers list, retrieve, update, partial_update, destroy
return Invoice.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
# create never goes through get_object, so set the owner here,
# from the session - never from the request body
serializer.save(owner=self.request.user)
In Express and Mongoose, the same idea is a scope helper that the handlers cannot bypass:
const owned = (req) => ({ userId: req.user.id });
app.get('/api/invoices', requireAuth, async (req, res) => {
res.json(await Invoice.find(owned(req)));
});
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOne({ _id: req.params.id, ...owned(req) });
return doc ? res.json(doc) : res.status(404).json({ error: 'Not found' });
});
app.patch('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOneAndUpdate(
{ _id: req.params.id, ...owned(req) },
{ $set: pick(req.body, ['amount', 'dueDate', 'notes']) }, // allowlist, not req.body
{ new: true }
);
return doc ? res.json(doc) : res.status(404).json({ error: 'Not found' });
});
app.delete('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOneAndDelete({ _id: req.params.id, ...owned(req) });
return doc ? res.status(204).end() : res.status(404).json({ error: 'Not found' });
});
Three things in there are doing real work, and one of them is not about IDOR at all.
Every miss returns the same 404. Not 403. A 403 tells the caller the record exists and belongs to someone else, which turns your status codes into an enumeration oracle and gives back some of what the scoping just took away.
The write handlers use findOneAndUpdate and findOneAndDelete, not the findById variants. This matters more than it looks. findByIdAndUpdate takes an id as its first argument, so there is no slot to put the ownership predicate in. The API shape quietly pushes you toward the unscoped call, and it is the one the model reaches for because it is the one in every tutorial.
The PATCH handler picks specific fields instead of spreading req.body. That is not the IDOR, it is mass assignment, and it is worth naming because it lives in the same handler and survives the IDOR fix untouched. Scoping the query stops a caller from editing someone else's invoice. It does nothing to stop them from setting userId or isPaid on their own.
Try SafeWeave in 30 seconds
npx safeweave-mcp
Works with Cursor, Claude Code, Windsurf, and VS Code. No signup required for the free tier — 3 scanners, unlimited scans.
Where This Approach Runs Out
A userId filter models exactly one thing: a resource with a single owner.
The moment invoices can be shared with an accountant, or belong to a workspace, or be visible to an org admin, the predicate is no longer a column comparison. It is a relationship question, and pushing it into every query by hand is how you end up with CVE-2026-47418: a real membership check on the route and a primary-key lookup underneath it. At that point the answer is a single authorization function that takes an actor, an action and a resource, called from one place, and a test that fails the build when a route reaches the database without going through it.
I am not going to pretend the one-line version scales to that. It scales to the app most of us are actually building this week, which is the one where the AI editor just wrote four routes in nine seconds.
FAQ
Q: Does adding auth middleware fix an IDOR? A: No. Authentication proves who is calling; IDOR is an authorization failure. The check has to prove the caller owns the specific record, which means the ownership condition belongs in the database query, not in a middleware that runs before the record is ever loaded.
Q: Why return 404 instead of 403 for a record owned by someone else? A: A 403 confirms the record exists, which turns your error codes into an enumeration oracle. Return an identical 404 whether the ID does not exist or belongs to another user, so the response tells an attacker nothing either way.
Q: If the detail route is scoped, is the list endpoint really a problem?
A: It is usually the bigger problem. An unscoped list endpoint leaks every record directly, and it also supplies the valid IDs that make an unscoped write route trivial to exploit. In Django REST Framework specifically, has_object_permission() never runs on the list action, so a correct permission class does nothing there.
I have been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags route handlers that resolve an ID straight into a database call with no ownership predicate, at the moment the route is written rather than in CI a day later. I will be straight about the limit, though: a missing ownership check is an absent condition, not a bad pattern, so no scanner catches every variant the way gitleaks catches a live key. The thing that actually holds is structural. One owner-scoped accessor, every handler forced through it, and no route where the check can be forgotten.
Secure your AI-generated code with SafeWeave
8 security scanners running in parallel, right inside your AI editor. SAST, secrets, dependencies, IaC, containers, DAST, license compliance, and security posture — all in one command.
No credit card required · 3 scanners free forever · Runs locally on your machine