The Request Body That Set is_admin to True
The privilege escalation was one field. The profile update endpoint accepted a JSON body and copied it onto the user record, and the user record had, among its columns, the flags that decide what the account may do. The attacker added one of those flags to the body they sent, and the endpoint, doing exactly what it was written to do, copied it in. There was no injection, no bypass, no clever payload. The feature was the vulnerability.
This is mass assignment, the defect where the convenience of binding a request directly onto a model turns every column into a writable field, and it remains one of the most common privilege escalations because it is not a mistake in the code. It is the code working as written.
This was a Node 22.14 API using an ORM's bulk update with the request body as the source object, and the shape is identical in every framework that offers a convenient binding.
Why the convenience is the hole
The endpoint wants to update a few fields the client may change, name, avatar, preferences. The convenient implementation takes the parsed body and passes it whole to the update, because listing the allowed fields feels like boilerplate. But the model has more fields than the client may change: the role flags, the ownership id, the verified marker, the billing tier. The bulk copy does not know the difference between the fields the form submits and the fields the schema happens to have, because the copy operates on the schema, not on the form.
So the writable surface is the entire model, and the only thing preventing a client from setting a privileged field is the client's own restraint. The client is the attacker. The restraint is absent.
The cruelty is that the hole is invisible in the happy path and in the API documentation, because the documentation describes the intended fields, while the implementation accepts the schema's fields, and the two lists differ by exactly the escalation.
Why review and testing miss it
The defect survives review because the diff reads as idiomatic, a one line binding that every codebase contains dozens of, and the reviewer's mental model of the endpoint is the intended field list, which matches the documentation, and neither the reviewer nor the documentation is the source of truth. The source of truth is the model schema, and nobody reads the schema during the binding's review.
It survives testing because the tests submit the intended fields and assert the intended updates, and the extra field is by definition not in any test, because the test author shares the reviewer's mental model. The attack is the first test that reads the schema.
The fix: bind to a shape you wrote
The durable fix is to put an explicit, written shape between the request and the model, and copy from the shape, not from the body.
Define a transfer object or a pick of the allowed fields, validate the input against it, and pass only that to the update:
const allowed = pick(body, ["name", "avatar", "preferences"]);
await users.update(id, allowed);
The allowlist is the policy, written where review can read it, and any field not on it is structurally unwritable, because it never reaches the update. The allowlist per role, where an admin endpoint allows more than a user endpoint, is the same pattern with a second dimension, and it makes the privilege boundary a visible list rather than an absence.
The inverse pattern, a denylist of forbidden fields, is the weaker fix, because it must remember every dangerous field in the schema, including the ones added next quarter, and the allowlist must remember only the fields the feature needs. The denylist fails by omission in the future. The allowlist fails by omission now, loudly, as a missing feature, which is the cheap direction to fail.
The sibling holes in the same family
While fixing the binding, audit the family, because the same "the client wrote the structure" defect appears in neighbours.
The ownership id taken from the body rather than the session, so the update targets another user's row, the authorisation half that pairs with mass assignment, and which the allowlist must also exclude, because the id in the body is the IDOR from what your incrementing order IDs tell your competitors.
The nested objects copied wholesale, where the body's nested structure overwrites a relation or a settings blob, which is mass assignment one level down, and which the same allowlist discipline must cover, because the nested field is a field.
The partial update semantics that treat absence as no change versus presence as null, where a client can null a field the form never submits, which is the binding interpreting the body's silence, and which the written shape must define deliberately.
The test that catches it
The test is one line of malice: submit the update with each privileged field set, and assert the field did not change. Generated from the schema, it is exhaustive by construction: every column the model has that the allowlist lacks becomes an assertion, and the next column added to the schema next quarter joins the test automatically. That generated test is the reviewer who reads the schema, run on every commit.
The rule
The request body is attacker written input, and binding it onto the model makes the schema the writable surface, which is the whole escalation. Bind to a written allowlist shape, per role, cover the nested fields, take ownership from the session never from the body, and generate the escalation test from the schema so the boundary is checked on every commit.
The convenience was never neutral. It was a decision about who may write each column, made implicitly, and the attacker is simply the first caller to read the schema the way the code reads it.