CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - Java
Overview
Mass assignment vulnerabilities in Java occur when Spring MVC/Boot automatically binds HTTP request parameters to object fields, allowing attackers to modify security-critical fields like isAdmin, role, or balance.
Primary Defence: Use DTOs with only user-modifiable fields, configure DataBinder allowlists with @InitBinder for @ModelAttribute binding, validate with @Valid, and never expose JPA entities directly in controller methods.
Defense-in-depth: CWE-915 (mass assignment) controls which properties can be set (e.g., excluding
isAdminfrom DTOs), while validation frameworks like Bean Validation validate the values of allowed properties (e.g., using@Size,@Min). Both protections are essential.
Common Vulnerable Patterns
Direct Entity Binding in Controllers
// VULNERABLE - direct entity binding allows over-posting
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String username;
private String email;
private Boolean isAdmin; // Security-critical!
private BigDecimal balance; // Should not be user-modifiable!
// getters and setters...
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@PostMapping
public User createUser(@RequestBody User user) {
// No restriction on which fields can be set!
return userRepository.save(user);
}
}
// Attack: POST /api/users
// { "username": "attacker", "email": "attacker@evil.com", "isAdmin": true, "balance": 999999 }
Why this is vulnerable: Spring's Jackson deserializer maps every JSON property that exists on the entity, including fields that are never shown in the UI. Any batch or import endpoint that deserializes directly to an entity has the same problem.
@ModelAttribute Without Restrictions
// VULNERABLE - @ModelAttribute binds all request parameters
@Controller
@RequestMapping("/users")
public class UserWebController {
@PostMapping("/update")
public String updateUser(@ModelAttribute User user) {
// All form parameters are bound to the User entity
userRepository.save(user);
return "redirect:/users";
}
}
// Attack: POST /users/update
// username=attacker&email=attacker@evil.com&isAdmin=true&balance=500000
Why this is vulnerable: @ModelAttribute binds every matching request parameter to object fields with no distinction between allowed and forbidden fields, so hidden or extra form fields become user-controllable.
BeanUtils.copyProperties Without Filtering
// VULNERABLE - BeanUtils copies all matching properties
import org.springframework.beans.BeanUtils;
@PostMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UpdateUserDTO updates) {
User user = userRepository.findById(id).orElseThrow();
// Copies ALL matching properties from updates to user, including isAdmin/balance
// if they exist on both UpdateUserDTO and User
BeanUtils.copyProperties(updates, user);
return userRepository.save(user);
}
Why this is vulnerable: BeanUtils.copyProperties performs reflection-based copying of every property with a matching name and type; it has no allowlist by default, so any field present on both the source and target objects is copied regardless of whether it should be user-editable. That makes the DTO the only thing standing between the request and the entity - add one security-critical field to UpdateUserDTO for an unrelated admin feature and this call starts writing it from user input. Spring's overload takes properties to skip (copyProperties(updates, user, "isAdmin", "balance")), but that is a denylist: it protects the fields someone remembered, not the fields added later.
Secure Patterns
Use DTOs for Input
// SECURE - DTO exposes only user-modifiable fields
public class CreateUserDTO {
@NotBlank
@Size(min = 3, max = 50)
private String username;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 8, max = 100)
private String password;
// isAdmin, balance NOT included - cannot be set by the caller
// getters and setters...
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping
public ResponseEntity<UserResponseDTO> createUser(@Valid @RequestBody CreateUserDTO dto) {
User user = new User();
user.setUsername(dto.getUsername());
user.setEmail(dto.getEmail());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setIsAdmin(false); // Explicitly set secure defaults
user.setBalance(BigDecimal.ZERO);
user.setCreatedDate(LocalDateTime.now());
User saved = userRepository.save(user);
return ResponseEntity.ok(new UserResponseDTO(saved));
}
}
Why this works: CreateUserDTO has no isAdmin or balance field, so Jackson has nothing to deserialize extra request properties into - they are ignored. Manual field-by-field mapping from DTO to entity keeps security-sensitive defaults explicit, and @Valid runs Bean Validation before the entity is created.
@InitBinder for Field Allowlists
// SECURE - @InitBinder restricts which fields @ModelAttribute can bind
@Controller
@RequestMapping("/users")
public class UserWebController {
@InitBinder
public void initBinder(WebDataBinder binder) {
// Only allow username and email to be bound
binder.setAllowedFields("username", "email");
}
@PostMapping("/update/{id}")
public String updateUser(@PathVariable Long id, @ModelAttribute User user) {
User existing = userRepository.findById(id).orElseThrow();
existing.setUsername(user.getUsername());
existing.setEmail(user.getEmail());
userRepository.save(existing);
return "redirect:/users";
}
}
Why this works: setAllowedFields() is a controller-scoped allowlist that Spring enforces before @ModelAttribute populates the target object - request parameters outside the list (isAdmin, balance) are never bound, regardless of what the form sends. DTOs are still preferred for new code; @InitBinder is most useful when retrofitting an existing @ModelAttribute endpoint.
Note the scope. The allowlist applies to @ModelAttribute binding - the only place WebDataBinder.bind() populates an object graph from request data. @RequestParam and @PathVariable arguments are resolved directly by their own argument resolvers rather than bound onto a target bean, so setAllowedFields() has no effect on them even though a WebDataBinder may still convert their raw value to the parameter's type. A @RequestBody parameter never goes through WebDataBinder either - the JSON is deserialized by an HttpMessageConverter - so setAllowedFields() adds nothing to a JSON endpoint, whatever else the controller does.
Jackson Access Control as a Defense-in-Depth Safety Net
// SECURE - READ_ONLY fields are serialized but never populated from JSON
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String username;
private String email;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Boolean isAdmin; // Appears in responses, ignored on input
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private BigDecimal balance;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Accept on input, never output
// getters and setters...
}
Why this works: Access.READ_ONLY tells Jackson to serialize the field but never populate it from incoming JSON, so even if an entity is accidentally exposed directly in a @RequestBody parameter, security-critical fields cannot be set that way. WRITE_ONLY is the mirror image for secrets that must be accepted but never returned. Treat both as a safety net, not the primary control - DTOs that never declare the field at all are still the preferred fix.
Reach for @JsonIgnore only when the field should be invisible in both directions. It is not the annotation for "never deserialize": it also removes the field from serialized output, so an admin console or account page that reads isAdmin off the same entity stops seeing it, and the change looks like an unrelated regression.
Framework-Specific Guidance
MapStruct
Generated mappers copy every field with a matching name by default. Exclude security-critical fields explicitly:
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "isAdmin", ignore = true)
@Mapping(target = "balance", ignore = true)
User toEntity(CreateUserDTO dto);
}
Without the ignore = true mappings, any field with a matching name on both the DTO and entity is copied silently - the same risk as BeanUtils.copyProperties.
Testing
- Normal input: submit only the intended fields and confirm the entity updates as expected.
- Boundary input: submit unknown fields, nested objects, and duplicate keys, and confirm behavior is consistent.
- Malicious input: add
isAdmin,role,balance, or an ownership field to the request body and confirm the value is ignored, not persisted. - Re-scan with the security scanner to confirm the finding is resolved.
Common Pitfalls
- Adding a
CreateUserDTOfor thePOSTendpoint but leaving aPATCH/bulk-update endpoint on the same controller still annotated@RequestBody User user- the DTO protects one action; every other method that deserializes JSON straight into the entity needs the same treatment. - Using
@InitBinder'ssetAllowedFields()on one controller but forgetting that@InitBinderis scoped per-controller (or per-@ControllerAdviceif declared globally) - a second controller that also binds the same entity with@ModelAttributehas no allowlist unless it declares its own@InitBinder, so the fix doesn't automatically propagate. - Adding
@JsonProperty(access = Access.READ_ONLY)toisAdminon the entity as the only fix, then later removing it (or adding a new sibling field likeroleswithout it) during an unrelated refactor - Jackson access control is a field-by-field annotation with no compiler enforcement, so it's easy for a new security-critical field to be added without anyone remembering to mark it. - Excluding
isAdmin/balancein a MapStruct mapper with@Mapping(target = "isAdmin", ignore = true)for the create-user mapping, but reusing the same entity type in a genericBeanUtils.copyProperties(dto, entity)call elsewhere for an admin bulk-edit feature -BeanUtilshas no knowledge of the MapStruct exclusions and copies every matching field name/type pair regardless.