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. Use DTOs with only permitted fields for JSON payloads, restrict form/query binding with @InitBinder, validate with Bean Validation (@Valid), and never bind directly to entity objects.
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
import org.springframework.web.bind.annotation.*;
@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 automatically maps all JSON properties to entity fields
- Attackers can include extra fields (
isAdmin,balance) not present in the UI - Enables privilege escalation by setting
isAdmin=true - Allows setting arbitrary account balances or modifying any entity field
Using @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 User entity
userRepository.save(user);
return "redirect:/users";
}
}
// Attack: POST /users/update
// username=attacker&email=attacker@evil.com&isAdmin=true&balance=500000
// Sets isAdmin and balance from form data!
Why this is vulnerable:
@ModelAttributebinds all matching request parameters to object fields- Attackers submit hidden form fields or manipulate requests with extra parameters
- Security-critical fields become user-controllable
- No distinction between allowed and forbidden fields
Using BeanUtils.copyProperties Without Filtering
// VULNERABLE - BeanUtils copies all properties
import org.springframework.beans.BeanUtils;
public class UpdateUserDTO {
private String email;
private Boolean isAdmin;
private BigDecimal balance;
// getters and setters...
}
@PostMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UpdateUserDTO updates) {
User user = userRepository.findById(id).orElseThrow();
// Copies ALL properties from updates to user!
BeanUtils.copyProperties(updates, user);
return userRepository.save(user);
}
// Attack: POST /api/users/123
// { "email": "new@example.com", "isAdmin": true, "balance": 999999 }
Why this is vulnerable:
BeanUtils.copyPropertiescopies all matching properties without restrictions- No allowlist or validation of which fields should be updated
- Attackers can modify any field by including it in the request
- Reflection-based copying treats all properties equally
Jackson Deserialization to Entities
// VULNERABLE - Direct JSON deserialization to entities
@PostMapping("/import")
public List<User> importUsers(@RequestBody List<User> users) {
// Users can contain isAdmin, balance, or any other field
return userRepository.saveAll(users);
}
// Attack: POST /api/users/import
// [{ "username": "user1", "email": "u1@example.com", "isAdmin": true }]
Why this is vulnerable:
- Jackson deserializes all JSON fields present in the payload
- Attackers craft JSON with additional fields to set administrative privileges
- Batch operations allow mass privilege escalation
- No field-level access control
MapStruct Without @Mapping Restrictions
// VULNERABLE - MapStruct mapping without field restrictions
@Mapper
public interface UserMapper {
User toEntity(UserDTO dto); // Maps ALL matching fields
}
@PostMapping
public User createUser(@RequestBody UserDTO dto) {
User user = userMapper.toEntity(dto);
return userRepository.save(user);
}
// If UserDTO has isAdmin field, it gets mapped to entity!
Why this is vulnerable:
- MapStruct automatically maps fields with matching names
- If DTO inadvertently includes security-critical fields, they get copied
- No explicit field allowlist
- Silent mapping of unexpected fields
Secure Patterns
Use DTOs for Input
// SECURE - DTO with only user-modifiable fields
import javax.validation.constraints.*;
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 user
// 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) {
// Map DTO to Entity with explicit field assignment
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:
- DTO exposure control:
CreateUserDTOonly exposesusername,email,password- extra request fields likeisAdminare ignored by Jackson - No binding surface: Security-critical properties (
isAdmin,balance) don't exist on DTO, eliminating over-posting attack vector - Explicit mapping: Manual field assignment makes security-sensitive defaults visible and auditable
- Bean Validation:
@Validtriggers validation annotations before any entity is created - Defense-in-depth: Even if attacker sends
isAdmin=true, no such field exists to deserialize to - Clear intent: Code explicitly shows which fields are user-controlled vs. server-controlled
Use @InitBinder for Field Allowlists
// SECURE - @InitBinder restricts which fields can be bound
@Controller
@RequestMapping("/users")
public class UserWebController {
@InitBinder
public void initBinder(WebDataBinder binder) {
// Only allow username and email to be bound
binder.setAllowedFields("username", "email");
// Alternative: Block specific fields
// binder.setDisallowedFields("isAdmin", "balance", "id");
}
@PostMapping("/update/{id}")
public String updateUser(@PathVariable Long id, @ModelAttribute User user) {
User existing = userRepository.findById(id).orElseThrow();
// Only username and email are bound from request
existing.setUsername(user.getUsername());
existing.setEmail(user.getEmail());
userRepository.save(existing);
return "redirect:/users";
}
}
Why this works:
- Field allowlist:
setAllowedFields()explicitly limits binding to safe fields only - Request fields ignored: Parameters not in allowlist (
isAdmin,balance) are not bound - Controller-scoped:
@InitBinderapplies to all methods in the controller - Defense-in-depth: Even with allowlist, manual assignment ensures correct values
- Audit trail: Allowlist makes security-critical decision visible
Note: While
@InitBinderprovides protection, DTOs are preferred for better separation of concerns and API contracts.
Separate DTOs for Different Operations
// SECURE - Separate DTOs for different operations
public class UpdateProfileDTO {
@NotBlank
@Size(max = 100)
private String displayName;
@Size(max = 500)
private String bio;
@URL
private String website;
// getters and setters...
}
public class UpdateEmailDTO {
@NotBlank
@Email
private String newEmail;
@NotBlank
private String currentPassword; // Require authentication
// getters and setters...
}
public class UpdateRoleDTO {
@NotBlank
private String role;
@NotBlank
private String reason; // Audit trail
// getters and setters...
}
@RestController
@RequestMapping("/api/users")
public class UserProfileController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@PutMapping("/{id}/profile")
@PreAuthorize("#id == authentication.principal.id")
public ResponseEntity<Void> updateProfile(
@PathVariable Long id,
@Valid @RequestBody UpdateProfileDTO dto) {
User user = userRepository.findById(id).orElseThrow();
user.setDisplayName(dto.getDisplayName());
user.setBio(dto.getBio());
user.setWebsite(dto.getWebsite());
userRepository.save(user);
return ResponseEntity.ok().build();
}
@PutMapping("/{id}/email")
@PreAuthorize("#id == authentication.principal.id")
public ResponseEntity<Void> updateEmail(
@PathVariable Long id,
@Valid @RequestBody UpdateEmailDTO dto,
@AuthenticationPrincipal UserDetails currentUser) {
User user = userRepository.findById(id).orElseThrow();
// Verify password before allowing email change
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
user.setEmail(dto.getNewEmail());
user.setEmailVerified(false); // Require re-verification
userRepository.save(user);
// Send verification email...
return ResponseEntity.ok().build();
}
@PutMapping("/{id}/role")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> updateRole(
@PathVariable Long id,
@Valid @RequestBody UpdateRoleDTO dto,
@AuthenticationPrincipal UserDetails admin) {
User user = userRepository.findById(id).orElseThrow();
// Audit role changes
auditLog.logRoleChange(
user.getId(),
dto.getRole(),
dto.getReason(),
admin.getUsername()
);
user.setRole(dto.getRole());
userRepository.save(user);
return ResponseEntity.ok().build();
}
}
Why this works:
- Operation-specific DTOs: Each operation has its own DTO exposing only relevant fields
- Minimal exposure: Profile updates can't change email, email updates can't change role
- Authorization layers: Regular users update profiles, email changes require password, role changes require admin privileges
- Re-authentication for sensitive changes: Email updates require password verification to prevent session hijacking abuse
- Audit trail: Critical changes are logged with reason and actor for accountability
- Method security:
@PreAuthorizeenforces access control at method level
MapStruct with Explicit @Mapping
// SECURE - MapStruct with explicit field mapping
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "isAdmin", ignore = true)
@Mapping(target = "balance", ignore = true)
@Mapping(target = "createdDate", ignore = true)
@Mapping(target = "password", ignore = true) // Handle separately
User toEntity(CreateUserDTO dto);
UserResponseDTO toDTO(User user);
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping
public ResponseEntity<UserResponseDTO> createUser(@Valid @RequestBody CreateUserDTO dto) {
User user = userMapper.toEntity(dto);
// Set secure defaults for ignored fields
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setIsAdmin(false);
user.setBalance(BigDecimal.ZERO);
user.setCreatedDate(LocalDateTime.now());
User saved = userRepository.save(user);
return ResponseEntity.ok(userMapper.toDTO(saved));
}
}
Why this works:
- Explicit ignore list:
@Mapping(target = "...", ignore = true)prevents automatic mapping of security-critical fields - Type-safe: Compiler error if field names change
- Clear documentation: Ignored fields are visible in mapper interface
- Secure defaults: Code explicitly sets values for ignored fields
- No silent mapping: MapStruct will warn if unmapped fields exist
Jackson @JsonIgnore and Custom Deserializer
// SECURE - @JsonIgnore prevents deserialization of sensitive fields
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String username;
private String email;
@JsonIgnore // Never deserialize from JSON
private Boolean isAdmin;
@JsonIgnore // Never deserialize from JSON
private BigDecimal balance;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Accept on input, never output
// getters and setters...
}
// Even better: Don't expose entities at all, use DTOs
Why this works:
- Deserialization blocking:
@JsonIgnoreprevents Jackson from setting these fields from JSON - Write-only passwords:
@JsonProperty(access = WRITE_ONLY)accepts password input but never returns it - Defense-in-depth: Even if entity is accidentally exposed, sensitive fields protected
- Caveat: DTOs are still preferred - this is a safety net only
Bean Validation Groups for Different Contexts
// SECURE - Validation groups for different operations
public interface CreateValidation {}
public interface UpdateValidation {}
public class UserDTO {
@Null(groups = CreateValidation.class) // Must be null on create
@NotNull(groups = UpdateValidation.class) // Must be present on update
private Long id;
@NotBlank(groups = {CreateValidation.class, UpdateValidation.class})
@Size(min = 3, max = 50)
private String username;
@NotBlank(groups = CreateValidation.class)
@Size(min = 8, max = 100, groups = CreateValidation.class)
private String password; // Only required on create
// getters and setters...
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponseDTO> createUser(
@Validated(CreateValidation.class) @RequestBody UserDTO dto) {
// password is required, id must be null
User user = convertAndSave(dto);
return ResponseEntity.ok(new UserResponseDTO(user));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponseDTO> updateUser(
@PathVariable Long id,
@Validated(UpdateValidation.class) @RequestBody UserDTO dto) {
if (dto.getId() != null && !id.equals(dto.getId())) {
return ResponseEntity.badRequest().build();
}
// id is required, password is optional
User user = updateExisting(id, dto);
return ResponseEntity.ok(new UserResponseDTO(user));
}
}
Why this works:
- Context-aware validation: Different validation rules for create vs. update
- Explicit constraints: Clear which fields are required in which context
- Type safety: Compile-time checking of validation groups
- ID consistency check: Update rejects payloads that attempt to change the path ID