Skip to content

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 isAdmin from DTOs), while validation frameworks like Bean Validation validate the values of allowed properties (e.g., using @Email, @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:

  • @ModelAttribute binds 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.copyProperties copies 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: CreateUserDTO only exposes username, email, password - extra request fields like isAdmin are 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: @Valid triggers 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: @InitBinder applies 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 @InitBinder provides 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: @PreAuthorize enforces 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: @JsonIgnore prevents 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

Additional Resources