User Controlled Ownership Field

ID

php.user_controlled_ownership_field

Severity

high

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Resource

Access Control

Language

Php

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 property of a persisted model that says whose record it is — the customer id, the owner id, the tenant id, 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 to themselves.

The detector reports three ways the value gets there:

  • the direct property write, $order→customer_id = $id;

  • Eloquent’s mass assignment — Order::create($data), $order→fill($data), $order→update($data) — where the property is never named at all, because the call writes every attribute the request carried;

  • the setter, $order→setCustomerId($id), in either the PSR or the snake-case spelling.

Rationale

This is CWE-915 (mass assignment) read as CWE-639 (authorization bypass through a user-controlled key): the property being assigned is not just any attribute, it is the one the authorization decision is later made on. Every subsequent "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

That the value is request-derived, and that it reaches the write: both are decided by the tainting analysis against the shipped library metadata. No profile is involved. The model a write lands on is resolved through the file’s namespace and use, so two models carrying a property of the same name are told apart.

Needs a profile fact

Which property denotes the model’s owner. Nothing in a rule catalogue can express it, because the next application calls the property something else; the application’s own convention profile names it (ownershipFields), and that fact supplies this rule’s sink.

Without that fact this rule has no sink and reports nothing, and says so in the scan log rather than 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 property name: that detector keeps the generic vocabulary (admin, role, scopes, balance) decided from the endpoint surface, and this one keeps the property the application’s profile named, with a flow proving the value reaches the model.

What the mass assignment can be shown to write

A mass assignment is reported only where the owner property can reach it. Two shapes say their keys out loud and are read:

  • a keyed array literal — $order→fill(['total' ⇒ $t, 'customer_id' ⇒ $c]) — where every key is written there, so a literal that does not name the owner property cannot write it;

  • a narrowed request — $request→only([…​]) and $request→except([…​]) — whose literal list of names is an allowlist and a denylist respectively.

Anything else, $request→all() or a variable, leaves the keys unknown and the write is reported: a silence there would be a finding withdrawn on a guess.

$fillable and $guarded

A mass assignment writes only what the model admits, so a $fillable that leaves the owner property out — or a $guarded that names it — withdraws the finding. That is read from the model’s own declaration, and only when the model is declared in the file being analysed: an analysis of one file cannot see a class declared in another. Where the declaration is out of reach the write is reported.

Not reported

  • A model reached through a query — Order::find(1)→customer_id = $id — because the static finders' return type is not resolved, so the variable holding the result carries no type.

  • A dynamic property write, $order→{$name} = $id, whose property name is a value rather than a member.

Remediation

Never let the request choose the owner. Set the property from the authenticated user on the server side, and keep it out of what is mass assigned:

public function store(Request $request)
{
    $order = Order::create([
        'total'       => $request->input('total'),
        'customer_id' => $request->user()->id, // server-derived, not from the request
    ]);

    return $order;
}

Declare $fillable explicitly and leave the owner property out of it — never $guarded = [], which admits everything. Validating the submitted value against the caller is a weaker fix than not accepting it, but it is 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, which the profiler establishes and which travel with the scan. With --ai-mode off (the default), or with a profile that names no owner property, the rule is inert.

© 2026 Xygeni. All rights reserved