User Controlled Ownership Field
ID |
javascript.user_controlled_ownership_field |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Resource |
Access Control |
Language |
JavaScript |
Tags |
ASVS50:v8.1.1, CWE:639, CWE:915, NIST.SP.800-53, OWASP:2025:A01, PCI-DSS:6.5.8 |
Description
Data that came from the request reaches the field of a persisted record that says whose record it is: the customer id, the owner id, the tenant id, or whatever this application calls it. The caller therefore decides which account a record belongs to. They can create a record under somebody else’s name, or move one of somebody else’s records to themselves.
The detector reports three ways the value gets there:
-
the direct write,
note.ownerId = req.body.ownerId; -
a property of the object a record is written from:
-
Sequelize:
Note.create({ ownerId }),note.set({ ownerId }),note.set('ownerId', v),Note.update({ ownerId }, { where }),Note.bulkCreate([…]); -
Mongoose:
new Folder({ ownerId }),Folder.updateOne(filter, { $set: { ownerId } }); -
Prisma:
prisma.project.create({ data: { ownerId } }), and thecreateandupdateof anupsert; -
MongoDB driver and MarsDB:
collection.insertOne({ ownerId }),comments.insert({ postedBy });
-
-
a whole-body copy, where the field is never named at all because the call writes every key the request carried:
Note.create(req.body),Note.create({ …req.body }),Object.assign(note, req.body).
Rationale
This is CWE-915 (mass assignment) read as CWE-639 (authorization bypass through a user-controlled key). The field being assigned is not just any field. It is the one the authorization decision is made on later, so every later "is this record yours?" check answers yes, because the request already said so.
Mined from the code |
Two things, both decided by the tainting analysis against the shipped library metadata: that the value comes from the request, and that it reaches the write. No profile is involved. The record’s model is read from the code, in the places each library puts it: * the model’s own binding ( Two models that carry a field of the same name are told apart. |
Needs a profile fact |
Which field denotes the model’s owner. No rule catalogue can express this, because the next
application calls the field something else. The application’s own convention profile names it
( |
Without that fact this rule has no sink and reports nothing. It says so in the scan log instead of
reporting a clean result it could not establish. Every finding carries the fact it used and the
file:line where that fact can be checked.
The boundary with the API Security mass-assignment detector is the provenance of the field name.
That detector keeps the generic vocabulary (admin, role, balance) decided from the endpoint
surface. This one keeps the field the application’s profile named, with a flow proving that the
value reaches the model.
The following are not reported:
-
a value that is the caller, such as
ownerId: req.user.id; -
a copy through a Sequelize allow-list that leaves the owner out, such as
Note.create(req.body, { fields: ['title'] }); -
a copy the handler hands back to the caller afterwards, for example
note.ownerId = req.user.idafterObject.assign(note, req.body); -
a read or a filter. A request-derived identifier that selects a record is a different finding.
Remediation
Never let the request choose the owner. Set the field on the server side from the authenticated user, and keep it out of what is copied:
router.post('/notes', async (req, res) => {
const note = await Note.create({
title: req.body.title,
ownerId: req.user.id, // server-derived, not from the request
});
res.status(201).json(note);
});
When a body has to be bound wholesale, bind it through an allow-list that leaves the owner out
(Sequelize fields, a DTO with explicit properties, pick). Then set the owner from the caller.
Checking the submitted value against the caller is a weaker fix than not accepting the value at all,
but it is still a fix: reject the request when the submitted owner is not the caller.