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 the create and update of an upsert;

    • 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.

What is mined and what needs a fact

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 (Note); * a Prisma delegate (prisma.project is Project); * the call that declares a model or opens a collection (mongoose.model('Folder', …), db.collection('attachments')); * the call a local was initialised from.

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 (ownershipFields), and that fact supplies this rule’s sink. A collection has no model, so the fact names the collection itself.

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.id after Object.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.

Configuration

The rule is driven by the ownershipFields facts of the application’s convention profile. The profiler establishes those facts, and they travel with the scan. With --ai-mode off (the default), or with a profile that names no owner field, the rule does nothing.

© 2026 Xygeni. All rights reserved