Bind the body to an input model record that declares only the fields users may edit (here ProfileUpdate(DisplayName, Bio)), never to the entity, and copy those fields onto the entity yourself. System.Text.Json skips undeclared properties. ASP.NET Core matches JSON names case-insensitively, so a blocklist that compares exact names misses Role.
- Find every endpoint that binds request data onto an entity: minimal API or MVC parameters typed as an EF Core entity, db.Update(input), Entry(entity).CurrentValues.SetValues(input), AutoMapper's Map(input, entity), TryUpdateModelAsync without a property list, and JsonPatchDocument<TEntity>.ApplyTo(entity).
- For each action, declare a record with only the fields that caller may change, here record ProfileUpdate(string? DisplayName, string? Bio) for PATCH /me, and bind the endpoint parameter to it. Give the admin screen its own record and endpoint rather than adding Role to this one.
- Copy the record's fields onto the entity one by one (if (changes.Bio is not null) user.Bio = changes.Bio), so a column added to the entity never becomes writable by accident.
- Decide about undeclared JSON properties: System.Text.Json skips them by default. To refuse them with 400 instead, add [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] to the record; minimal APIs answer 400 when the JSON body cannot be deserialized.
- Set Role, TenantId, OwnerId and similar properties only in server-side code: an admin endpoint behind its own authorization policy, sign-up, an invitation.
- Add WebApplicationFactory regression tests that send an ordinary update plus role, plus tenantId and plus case variants of both, then read the stored entity rather than only the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public static void Map(IEndpointRouteBuilder routes) =>
routes.MapPatch("/me", (User changes, ClaimsPrincipal principal, UserStore users) =>
{
var user = users.Current(principal);
foreach (var property in typeof(User).GetProperties())
{
if (property.GetValue(changes) is { } value)
{
property.SetValue(user, value);
}
}
return Results.Ok(user);
});With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix
/// <summary>
/// The fields a user may change on their own profile. Role and TenantId are not here: only
/// server-side code sets them. System.Text.Json skips JSON properties a type does not declare.
/// </summary>
public sealed record ProfileUpdate(string? DisplayName, string? Bio);
public static void Map(IEndpointRouteBuilder routes) =>
routes.MapPatch("/me", (ProfileUpdate changes, ClaimsPrincipal principal, UserStore users) =>
{
var user = users.Current(principal);
if (changes.DisplayName is not null)
{
user.DisplayName = changes.DisplayName;
}
if (changes.Bio is not null)
{
user.Bio = changes.Bio;
}
return Results.Ok(user);
});With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
private static readonly string[] ServerOwnedFields = { "role", "tenantId" };
public static void Map(IEndpointRouteBuilder routes) =>
routes.MapPatch("/me", (JsonElement body, ClaimsPrincipal principal, UserStore users) =>
{
foreach (var property in body.EnumerateObject())
{
if (ServerOwnedFields.Contains(property.Name))
{
return Results.BadRequest(new { error = $"{property.Name} cannot be changed" });
}
}
// Bind the body with the options ASP.NET Core uses for request bodies.
var changes = body.Deserialize<User>(JsonSerializerOptions.Web)!;
var user = users.Current(principal);
foreach (var property in typeof(User).GetProperties())
{
if (property.GetValue(changes) is { } value)
{
property.SetValue(user, value);
}
}
return Results.Ok(user);
});With IMPL=wrong, 1 of 6 tests fail (1 exploit test).
Routes: every route needs a signed-in user
// Every route needs a signed-in user. The ClaimsPrincipal comes from the authentication
// middleware, so PATCH /me always edits the signed-in user's own record.
var routes = app.MapGroup("").RequireAuthorization();
switch (impl)
{
case "vulnerable":
VulnerableProfile.Map(routes);
break;
case "fixed":
FixedProfile.Map(routes);
break;
case "wrong":
WrongProfile.Map(routes);
break;
}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: a signed-in member sends extra fields in an ordinary profile update.</summary>
public sealed class ExploitTests(ProfileApiFactory factory) : ProfileApiTests(factory)
{
// The server may ignore the extra fields (200) or refuse the request (400). Either way the
// stored record keeps the role and tenant the server gave it. (User has no other fields to
// gain, unlike a dictionary or a document.)
private void AssertServerFieldsUnchanged((HttpStatusCode Status, string Body) res)
{
Assert.True(res.Status is HttpStatusCode.OK or HttpStatusCode.BadRequest,
$"expected 200 or 400, got {(int)res.Status}: {res.Body}");
Assert.Equal("member", Alice.Role);
Assert.Equal("red", Alice.TenantId);
}
[Fact]
public async Task RoleInTheBodyDoesNotMakeAMemberAnAdmin() =>
AssertServerFieldsUnchanged(await PatchMe("alice", """{"displayName": "Alice Martin", "role": "admin"}"""));
[Fact]
public async Task TenantIdInTheBodyDoesNotMoveTheUserToAnotherTenant() =>
AssertServerFieldsUnchanged(await PatchMe("alice", """{"displayName": "Alice Martin", "tenantId": "blue"}"""));
[Fact]
public async Task CaseVariantsRoleAndTenantIdDoNotChangeTheRecordEither() =>
AssertServerFieldsUnchanged(await PatchMe("alice", """{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}"""));
}- ASP.NET Core binds JSON with JsonSerializerDefaults.Web, which sets PropertyNameCaseInsensitive, so "Role", "ROLE" and "role" all set Role. This card's wrong implementation refuses role and tenantId by exact name, which looks fixed when a retest sends those names, and Role and TenantId still land on the entity.
- Name comparison depends on the type you bind. A JsonObject that ASP.NET Core binds inherits the case-insensitive setting from the web defaults (a probe confirmed Remove("role") also removed Role), but a Dictionary<string, JsonElement>, a JsonElement or a JsonDocument compares names exactly. Do not filter field names by hand; bind an input model.
- EF Core's Entry(entity).CurrentValues.SetValues(input) copies every property whose name matches one of the entity's, from an object or a dictionary, and db.Update(entityFromRequest) tracks that entity and every related entity it carries as Modified. Either way SaveChanges writes Role and TenantId too.
- Mapping libraries such as AutoMapper copy members by matching names, so Map(input, entity) is only as safe as the input type. Map from the input model, never from a bound entity or a dictionary.
- Reusing the admin's UserUpdate record, which has Role, for PATCH /me reopens the hole. Use one input model per action.
- Hidden form fields and read-only inputs are not protection: the client can send any JSON it likes.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/csharp-aspnetcore does this for you).
- Against your own API, sign in as an ordinary member, replay a normal profile update with role, tenantId and their case variants (Role, TENANTID) added, then read the entity from the database: Role and TenantId must be unchanged.
- Do not rely on a scanner: Semgrep's C# mass assignment rule only follows MVC action parameters into View(), so it cannot see minimal APIs or JSON APIs, and CodeQL has no C# query for it.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.dotnet.security.audit.mass-assignment.mass-assignment | No findings on src/. The rule is for MVC: it follows a parameter of a public action that returns IActionResult or ActionResult, in a file that imports Microsoft.AspNetCore.Mvc, into View(...), and skips parameters marked [Bind(...)]. On a probe controller it flagged an action that binds a User entity and returns View(user), and not the same binding in an API action that returns Ok(user). Minimal API endpoints are outside its patterns.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/owasp-top-ten | No findings on src/.Checked: Semgrep 1.180.0 (CE) |
In semgrep-rules develop at 9bbf021, mass-assignment is the only C# rule tagged CWE-915. github/codeql main at b5b8165 has no C# query tagged CWE-915. The regression tests are the control.
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API (System.Text.Json) |
| Microsoft.AspNetCore.App (shared framework: minimal APIs, System.Text.Json) | 10.0.12 at run time |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform 2.4.0) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/csharp-aspnetcorefolder.
dotnet restore tests/Profile.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Profile.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Profile.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Profile.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- mvcTesting 10.0.12
- xunitV3 4.0.1
- microsoftTestingPlatform 2.4.0
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (all 3 exploit tests); wrong 1/6 fail (the case-variant exploit test: the exact-name check answers 400 to role and tenantId, and the case-insensitive binding sets Role and TenantId); offline in a no-network sandbox, after installs with warm and with empty package caches
- Dependency freshness
- The app project has no package references; it uses only the ASP.NET Core shared framework. Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) and xunit.v3 4.0.1 (2026-09-12) match the other C# cards; xunit.v3 4.0.2 (2026-10-09) is a bug-fix release under 14 days old. dotnet list package --vulnerable found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- ASP.NET Core tutorial: Create a minimal API, Prevent over-posting
- ASP.NET Core: Minimal API parameter binding (JSON body options, binding failures)
- System.Text.Json: Web defaults for JsonSerializerOptions
- System.Text.Json: Handle unmapped members during deserialization
- EF Core: Accessing tracked entities (CurrentValues.SetValues)