User Controlled Ownership Field
ID |
python.user_controlled_ownership_field |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Resource |
Access Control |
Language |
Python |
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 attribute 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 five ways the value gets there:
-
the direct attribute write,
order.customer_id = customer; -
setattr(order, "customer_id", customer); -
the keyword argument,
Order(customer_id=customer)andOrder.objects.create(customer_id=customer); -
a whole-dict copy —
Order(data),Order.objects.create(data)— where the attribute is never named at all, because the call writes every key the request carried; -
the setter,
order.set_customer_id(customer).
Rationale
This is CWE-915 (mass assignment) read as CWE-639 (authorization bypass through a user-controlled key): the attribute 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.
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 — the request accessors of the framework in use. No
profile is involved. The model a write lands on is resolved from the assignment that constructed it,
from a parameter’s type hint, or from the |
Needs a profile fact |
Which attribute denotes the model’s owner. Nothing in a rule catalogue can express it, because
the next application calls the attribute something else; the application’s own convention profile
names it ( |
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 attribute name:
that detector keeps the generic vocabulary (admin, role, scopes, balance) decided from the
endpoint surface, and this one keeps the attribute the application’s profile named, with a flow
proving the value reaches the model.
A manager call that reads rather than writes — Order.objects.filter(**data) — is not reported
here: a request-derived identifier reaching a query is a different finding.
Not reported
-
A model reached through a manager query —
found = Order.objects.get(pk=1), thenfound.customer_id = customer— because the manager’s return type is not resolved, so the variable holding the result carries no type. -
A DRF serializer or a
ModelFormwhosesave()writes the attribute, because the model is named in an innerMetaclass rather than anywhere on the call.
Remediation
Never let the request choose the owner. Set the attribute from the authenticated user on the server side, and keep it out of what is bound:
def create_order(request):
submitted = json.loads(request.body)
return Order.objects.create(
total=submitted['total'],
customer_id=request.user.id, # server-derived, not from the request
)
With Django REST Framework, declare the owner attribute read_only on the serializer and set it in
perform_create(serializer); with a ModelForm, list fields explicitly and leave the owner out.
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.