Mass assignment in ASP.NET Core: over-posting explained
Mass assignment in ASP.NET Core: the binder sets any property a request names, so extra JSON flips IsAdmin. Why validation misses it and the allowlist fix.
The ticket says customer 4471 has administrator rights and nobody granted them. The audit trail
shows one request from the customer's own session, PUT /api/profile, with a body that is mostly
what the profile form sends: a display name, a bio, and one extra field, "isAdmin": true. You
search the repository for code that assigns IsAdmin from a request. There is none. There does
not need to be. The endpoint binds the body to the User entity, the deserializer sets every
property whose name it can match, and SaveChanges writes the result. The request decided which
columns changed.
That is mass assignment, also called over-posting, and it is the .NET-flavored entry in the OWASP Top 10 for C#. It rarely shows up in a code review because the vulnerable code is the shortest possible version of the feature.
The binder fills whatever it can match
Model binding and JSON deserialization are name matching. For every public settable property on
the target type, look for a source value with that name; if one exists, convert it and set it.
Nothing in that loop knows which properties you meant to expose. It only knows which ones exist.
ASP.NET Core's web defaults make the match case-insensitive, so "isadmin" lands as surely as
"IsAdmin".
public class User
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public string Bio { get; set; } = "";
public bool IsAdmin { get; set; }
public decimal Balance { get; set; }
public string PasswordHash { get; set; } = "";
}
app.MapPut("/api/profile", async ([FromBody] User user, AppDbContext db) =>
{
db.Users.Update(user);
await db.SaveChangesAsync();
return Results.NoContent();
});
The form sends two fields. The attacker sends five:
{ "id": 4471, "displayName": "Ada", "bio": "Analytical engines",
"isAdmin": true, "balance": 99999 }
Every one of them has a property to land on. The MVC form-post variant is the same bug with a
different method name: load the entity, call TryUpdateModelAsync(user), and the binder copies
every matching form field, including the hidden inputs the attacker added in the browser's dev
tools.
One request body, two binding targets
The bound type's shape is the write policy
{ "displayName" : "Ada" , "bio" : "Analytical engines" , "isAdmin" : true , "balance" : 99999 , "id" : 1 }
Bound to the entity
[FromBody] User user, then SaveChanges
5 settable properties - displayName written
- bio written
- isAdmin privilege written
- balance money written
- id identity written
the request chose which columns changed
Bound to a request DTO
UpdateProfileRequest(DisplayName, Bio)
2 settable properties - displayName mapped written
- bio mapped written
- isAdmin no property dropped
- balance no property dropped
- id no property dropped
the code chose which columns can change
Validation cannot see it
The reflex is to reach for data annotations. [Required], [StringLength(80)], [EmailAddress],
and ModelState.IsValid are all about whether the values that arrived are well-formed. true is a
perfectly well-formed boolean. 99999 is a perfectly well-formed decimal. Validation answers "is
this value acceptable for this property" and never asks "should this request be allowed to set
this property at all". The second question is the whole vulnerability.
There is a quieter second effect in that endpoint. Update(user) marks every property as modified,
so a field the client did not send is written back as its default. A legitimate profile update
that omits balance writes 0, and one that omits passwordHash writes an empty string. Binding
straight to the entity over-posts what you did not want set and under-posts what you did not want
changed, in the same request.
The shape of the type you bind to is your write policy. If that type is the entity, the policy is "anything the request names", and the attacker learns your schema by guessing.
The fixes, strongest first
A request DTO with only the intended fields, mapped explicitly. This is the fix that removes the bug rather than filtering it. The extra JSON has no property to land on and is dropped by the deserializer, and the mapping code is a visible list of what a user may change:
public sealed record UpdateProfileRequest(string DisplayName, string Bio);
app.MapPut("/api/profile", async (
UpdateProfileRequest request, ClaimsPrincipal principal, AppDbContext db) =>
{
var userId = int.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync(userId);
if (user is null) return Results.NotFound();
user.DisplayName = request.DisplayName; // the allowlist, in code
user.Bio = request.Bio;
await db.SaveChangesAsync();
return Results.NoContent();
});
Notice the id comes from the authenticated principal, not the body. That closes the other half
of the payload above, where a caller renames someone else's profile by posting their id. Loading
the entity first and assigning two properties also fixes the under-posting: columns the request
does not mention keep their values, because the tracker only writes what changed.
[Bind] include-lists. MVC lets you declare which properties the binder may set on an action
parameter, [Bind("DisplayName,Bio")] User user. It is a real allowlist, and it has a caveat that
the model binding documentation
states in one sentence: [Bind] does not affect input formatters. It governs form, route, and
query-string binding. A JSON body goes through an input formatter, so on a JSON API the attribute
does nothing and the payload above still lands. For MVC form flows, the overload
TryUpdateModelAsync(user, "", u => u.DisplayName, u => u.Bio) is the same idea with the list
next to the entity load, where the next reader will see it.
[BindNever] on entity properties. Marking IsAdmin and Balance as never bindable is a
denylist. It protects the columns someone thought of, and the next sensitive column added to the
entity is writable by default until someone remembers the attribute. It also shares the input
formatter caveat: [BindNever] is a model-binding attribute, and a JSON body does not consult it.
[JsonIgnore] on the entity. This does reach the JSON path, and it reaches it in both
directions: the admin screen that needs to read IsAdmin now cannot serialize it either. The
usual next step is a separate output model, at which point you have two models and might as well
make the inbound one the DTO from the first option.
The OWASP mass assignment cheat sheet lists the same three mechanisms, allowlist, blocklist, and DTOs, with the per-framework spellings. The ordering above is the one that survives the next schema change.
PATCH has the same hole with a different spelling
Partial updates make the bug look like a feature. A JSON Patch document applied to the entity
accepts { "op": "replace", "path": "/isAdmin", "value": true } for the same reason the binder
did: the path names a property that exists. A merge-patch handler that walks a dictionary of
field names and sets each one by reflection is the same loop, written by hand. The fix does not
change. Apply the patch to a DTO that only has the editable properties, or check every path
against an explicit set before touching the entity:
private static readonly HashSet<string> Editable =
new(StringComparer.OrdinalIgnoreCase) { "displayName", "bio", "avatarUrl" };
foreach (var (field, value) in patch)
{
if (!Editable.Contains(field))
return Results.BadRequest($"Field '{field}' is not editable.");
Apply(user, field, value);
}
Reject rather than silently drop. A payload that names isAdmin is a probe, and a 400 with a log
line is how you find out someone is probing. A first attempt at this guard usually reads
field != "id", which is the blocklist again: it protects the one field its author thought of and
leaves role, isAdmin, passwordHash, and emailVerified open.
Allowlists over denylists, everywhere the caller picks
Mass assignment is one instance of a shape that recurs across an API surface: the caller supplies
a value, and that value selects server behavior. A sort column interpolated into ORDER BY is
SQL injection through an identifier, and the fix is a fixed
set of sortable names. A pageSize passed straight to the query is a denial of service in one
parameter, and the fix is a clamp to a known range. A file name from the request is
path traversal, and a URL from the request is
SSRF; both end in "enumerate what is allowed and refuse the rest". In
each case the denylist loses the moment the world adds a value its author did not anticipate, and
the allowlist wins because it never had to anticipate anything.
The property list on a DTO is the same allowlist, expressed as a type. That is why it is the strongest fix: it is checked by the compiler, visible in the signature, and impossible to forget on the next field.
Practice against payloads you would not write
Over-posting is awkward to test by hand because the dangerous request is one your own client will never send. Your integration tests post the form's two fields and pass. The attacker posts the entity's other five.
Katabench's Secure Coding exercises include "Mass Assignment
Guard", where the starter is the field != "id" blocklist above and the adversarial suite sends
role, isAdmin, and friends and expects each one refused while the editable fields still pass.
"Clamp the Page Size" and "Sort Column Allowlist" are the same allowlist shape applied to a page
size and an ORDER BY identifier, each graded by a functional suite for the honest inputs and an
adversarial suite for the rest. The "Secure Web APIs in C#" learning path opens with a
section called "Validate by allowlist" for exactly this reason: once the reflex is "list what is
permitted", the next [FromBody] User in a code review reads as the bug it is.