Skip to content
Katabench
Try free
8 min read The Katabench team

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

PUT /api/profile

{ "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 attributes check the values that arrive. Only the shape of the bound type decides which properties can arrive at all, so the DTO is the allowlist.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.