User Controlled Ownership Field
ID |
kotlin.user_controlled_ownership_field |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Resource |
Access Control |
Language |
Kotlin |
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 entity 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.
Kotlin does not write that property the way Java does. A property assignment is the setter, and the idiom that replaces mass assignment is the one where the field is named as an argument. The detector reports:
-
the property assignment,
order.customerId = request.getParameter("customerId"); -
the same assignment inside a scope function,
Order().apply { customerId = id }andorder.also { it.customerId = id }, where the receiver is not written at the point of the write; -
the named argument of a
copyor a constructor —order.copy(customerId = id),Order(customerId = id). A data class is immutable by default, socopyis how an application that follows the idiom rewrites a record; -
the call named after the field,
order.setCustomerId(id), for an entity declared in Java; -
a binder that copies request data into the entity wholesale —
mapper.readValue(body, Order::class.java),Json.decodeFromString<Order>(body),BeanUtils.copyProperties(form, order)— where the property is never named at all, because the binder writes every property the entity has.
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.
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 annotations and accessors of the framework in use. No profile is involved. |
Needs a profile fact |
Which property denotes the entity’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 ( |
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 field 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 entity.
What the detector cannot see
The entity has to be nameable at the point of the write, and Kotlin names it whenever the receiver
was declared with a type — a parameter, a class property, an explicitly typed local, this — or
whenever the receiver’s own declaration constructs it, as in val order = Order(). Where a local
takes its type from a call instead, as in val order = repo.find(id), nothing in the file says what
that local is, and the write is not reported. Writing the type — val order: Order = repo.find(id)
— is enough to make it visible.
Remediation
Never let the request choose the owner. Set the property from the authenticated principal on the server side:
@PostMapping("/orders")
fun create(@RequestBody form: OrderForm, principal: Principal): Order {
val order = Order(
description = form.description,
customerId = currentCustomerId(principal) // server-derived, not from the request
)
return orders.save(order)
}
Where a whole-body binder is used, keep the owner property off the bindable surface: a dedicated
request DTO that simply has no such property, @JsonIgnore on the entity’s property, an explicit
allow list (Jackson’s @JsonProperty(access = READ_ONLY)), or a @PrePersist/@PreUpdate hook that
overwrites it from the session. Making the property a val set only through the constructor removes
the assignment shapes outright. 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.