User Controlled Ownership Field
ID |
go.user_controlled_ownership_field |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Resource |
Access Control |
Language |
Go |
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 struct 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 four ways the value gets there:
-
the direct field write,
order.CustomerID = id; -
the composite literal,
Order{Total: total, CustomerID: id}; -
a whole-body copy into the struct —
json.Unmarshal(body, &order),json.NewDecoder(r.Body).Decode(&order),c.ShouldBindJSON(&order),c.BodyParser(&order)— where the field is never named at all, because the binder writes every exported field the struct has; -
a generated setter,
order.SetCustomerID(id), asentand protobuf produce.
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 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 router in use. No profile is involved. The struct a write lands on is resolved to its declared type, so two structs carrying a field of the same name are told apart. |
Needs a profile fact |
Which field denotes the struct’s owner. Nothing in a rule catalogue can express it, 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, 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 field the application’s profile named, with a flow proving
the value reaches the struct.
Remediation
Never let the request choose the owner. Set the field from the authenticated caller on the server side:
func CreateOrder(w http.ResponseWriter, r *http.Request) {
var form OrderForm
json.NewDecoder(r.Body).Decode(&form)
order := Order{
Total: form.Total,
CustomerID: customerFromSession(r), // server-derived, not from the request
}
save(&order)
}
Where a whole-body binder is used, keep the owner field off the bindable surface: bind into a
dedicated request struct that simply has no such field, tag the struct field json:"-", and
overwrite the owner from the session before persisting. 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.