Bind @RequestBody to a 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. Do not use readerForUpdating(entity) or BeanUtils.copyProperties on request data. Jackson 3 ignores undeclared JSON properties by default, so role and tenantId never reach the entity.
- Find every handler that binds request data onto an entity: @RequestBody or @ModelAttribute parameters typed as an entity, objectMapper.readerForUpdating(entity) or updateValue(entity, ...), BeanUtils.copyProperties(input, entity), MapStruct mappings into the entity and Spring Data REST repositories exposed for PUT and PATCH.
- 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 @RequestBody 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() != null) user.setBio(changes.bio())), so a column added to the entity never becomes writable by accident.
- Decide about undeclared JSON properties: Jackson 3 ignores them by default. To refuse them with 400 instead, set spring.jackson.deserialization.fail-on-unknown-properties=true, which applies to every endpoint.
- Set role, tenantId, ownerId and similar fields only in server-side code: an admin endpoint behind its own authorization check, sign-up, an invitation.
- Add MockMvc regression tests that send an ordinary update plus role, plus tenantId and plus case variants of both, then load the entity rather than only reading 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
@PatchMapping("/me")
User update(Principal principal, @RequestBody JsonNode changes) {
User user = users.currentUser(principal);
// Jackson calls the entity's setter for every property named in the body.
json.readerForUpdating(user).readValue(changes);
return user;
}With IMPL=vulnerable, 2 of 6 tests fail (2 exploit tests).
The fix
/**
* The fields a user may change on their own profile. role and tenantId are not here:
* only server-side code sets them. Jackson 3 ignores JSON properties a type does not
* declare (FAIL_ON_UNKNOWN_PROPERTIES is off by default).
*/
record ProfileUpdate(String displayName, String bio) {}
@PatchMapping("/me")
User update(Principal principal, @RequestBody ProfileUpdate changes) {
User user = users.currentUser(principal);
if (changes.displayName() != null) {
user.setDisplayName(changes.displayName());
}
if (changes.bio() != null) {
user.setBio(changes.bio());
}
return user;
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
@PatchMapping("/me")
User update(Principal principal, @RequestBody ObjectNode changes) {
User user = users.currentUser(principal);
changes.remove("role"); // role is set by admins only
json.readerForUpdating(user).readValue(changes);
return user;
}With IMPL=wrong, 1 of 6 tests fail (1 exploit test).
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.
// Exploit: a signed-in member sends extra fields in an ordinary profile update.
// 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 map or a document.)
private void assertServerFieldsUnchanged(MockHttpServletResponse res) throws Exception {
assertThat(res.getStatus()).as("status, body %s", body(res)).isIn(200, 400);
User stored = users.find("alice");
assertThat(stored.getRole()).as("role").isEqualTo("member");
assertThat(stored.getTenantId()).as("tenantId").isEqualTo("red");
}
@Test
void exploitRoleInTheBodyDoesNotMakeAMemberAnAdmin() throws Exception {
assertServerFieldsUnchanged(patchMe("alice", """
{"displayName": "Alice Martin", "role": "admin"}"""));
}
@Test
void exploitTenantIdInTheBodyDoesNotMoveTheUserToAnotherTenant() throws Exception {
assertServerFieldsUnchanged(patchMe("alice", """
{"displayName": "Alice Martin", "tenantId": "blue"}"""));
}
@Test
void exploitCaseVariantsRoleAndTenantIdDoNotChangeTheRecordEither() throws Exception {
assertServerFieldsUnchanged(patchMe("alice", """
{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}"""));
}- @JsonIgnoreProperties or @JsonProperty(access = READ_ONLY) on the entity is a blocklist: it protects the properties you annotated, and every property added later is writable. This card's wrong implementation removes role from the JSON and lets tenantId through, with the same effect.
- @ModelAttribute and form binding go through DataBinder, which by default binds request parameters to every public setter, nested paths included. Spring's guidance is a record or dedicated input object, or setAllowedFields in an @InitBinder method; it calls setDisallowedFields fragile and says it is due to be deprecated in Spring Framework 7.1.
- Jackson 3 turned FAIL_ON_UNKNOWN_PROPERTIES off by default (Jackson 2 had it on, and Spring Boot switched it off). That is why the record drops role quietly, and also why a probe for role succeeds with 200 and leaves no trace. Turn the feature on if you want such requests refused with 400.
- Jackson matches property names case-sensitively unless spring.jackson.mapper.accept-case-insensitive-properties is on. With it on, a blocklist that compares exact names misses Role, as the ASP.NET Core card shows.
- An input record is only as safe as its field list. Reusing the admin's UserUpdate record, which has role, for PATCH /me reopens the hole; use one record 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/java-spring 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 added, then load the entity from the database: role and tenantId must be unchanged.
- Do not rely on a scanner: no Semgrep or CodeQL rule covers mass assignment in Spring, Semgrep's OWASP Top 10 and Java packs reported nothing on the vulnerable code, and Find Security Bugs' ENTITY_MASS_ASSIGNMENT only looks for JPA, JDO and MongoDB entity types in controller signatures.
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 | p/owasp-top-ten | No findings on src/main/java: the vulnerable, fixed and wrong controllers look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE) |
| Find Security Bugs (SpotBugs plugin) | ENTITY_MASS_ASSIGNMENTMass assignment | Not expected to report this card. It reports a @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping or @PatchMapping method whose parameter type is annotated @Entity (javax or jakarta persistence), JDO PersistenceCapable or Spring Data MongoDB @Document. This card's User is a plain class, and the vulnerable controller binds a JsonNode and copies it onto the entity with readerForUpdating, which a signature check cannot see. Binding @RequestBody to a JPA entity would be reported.Checked: Detector source read at find-sec-bugs master 90447f7 (SpringEntityLeakDetector.java); not executed |
No Semgrep or CodeQL rule covers mass assignment in Spring: semgrep-rules develop at 9bbf021 has no Java or Kotlin rule tagged CWE-915, and github/codeql main at b5b8165 has no Java query tagged CWE-915. The regression tests are the control.
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, Jackson 3) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 (Spring Framework 7.0.9) |
| tools.jackson:jackson-bom (overrides Boot's 3.1.5) | 3.1.7 (jackson-databind 3.1.7) |
| org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24) | 11.0.26 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs mass-assignment/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1 (Homebrew)
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- jackson 3.1.7
- tomcat 11.0.26
- junitJupiter 6.0.3
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 2 of 6 tests fail (2 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
- Test results
- fixed 6/6 pass; vulnerable 2/6 fail (the role and tenantId exploit tests); wrong 1/6 fail (the tenantId exploit test). The case-variant test passes against all three because Jackson matches property names case-sensitively and ignores Role and TenantId; offline in a no-network sandbox, after installs with warm and with empty package caches
- Dependency freshness
- Spring Boot 4.1.1 was published on 2026-08-20. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which have advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7, so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (jackson-databind 3.1.7 reached Maven Central on 2026-09-22), as the other Spring cards do. OSV listed no advisories for the 72 resolved artifacts on 2026-10-10. Maven has no lockfile; the Boot parent and the two overrides fix every version.
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
- Spring Framework: Data Binding, Model Design
- Spring Boot: Customize the Jackson JsonMapper (spring.jackson.deserialization.*, spring.jackson.mapper.*)
- Jackson 3.1.7 ObjectMapper Javadoc (readerForUpdating, updateValue)
- Find Security Bugs: Mass assignment (ENTITY_MASS_ASSIGNMENT)