이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Mass Assignment는 왜 단순 입력 검증 문제가 아니라 객체 바인딩 경계 문제인가?
- 요청 DTO에는 어떤 필드가 들어가야 하고 어떤 필드는 절대 들어가면 안 되는가?
- Spring MVC의 data binding과 domain model을 어떻게 분리해야 권한 필드 오염을 막을 수 있는가?
개요
Mass Assignment는 프레임워크가 요청 parameter나 JSON body를 객체에 자동 바인딩할 때, 개발자가 의도하지 않은 필드까지 함께 설정되는 취약점이다.
위험한 점은 공격 요청이 매우 정상적으로 보인다는 것이다. 화면에는 없는 role, isAdmin, point, emailVerified, ownerId, price 필드를 JSON에 추가했을 뿐인데 서버가 entity에 직접 바인딩하면 민감 상태가 바뀔 수 있다.
DTO는 “entity와 이름이 비슷한 객체”가 아니라 “이 endpoint에서 사용자가 바꿀 수 있는 필드의 allowlist”다. DTO에 민감 필드가 들어 있으면 DTO를 쓴다는 사실만으로는 방어가 되지 않는다.
이 문서의 핵심은 자동 바인딩을 편의 기능으로만 보지 않고 신뢰 경계로 보는 것이다.
공격 시나리오
- 공격자는 profile update, user registration, cart update, order update처럼 객체 일부를 수정하는 API를 찾는다.
- 브라우저 화면이나 모바일 앱에는 없지만 응답 JSON에 보이는 필드명을 요청 body에 추가한다.
- 서버가 request body를 entity에 직접 바인딩하면 민감 필드가 같이 바뀐다.
PATCHAPI가 부분 업데이트를 지원하면서 unknown field를 조용히 무시하면 탐지가 늦어진다.- 관리자 전용 endpoint와 사용자 endpoint가 같은 DTO를 공유하면 권한이 낮은 사용자가 관리자 필드를 보낼 수 있다.
- 피해는 권한 상승, 포인트 조작, 결제 금액 변경, 소유자 변경, 이메일 인증 우회로 이어진다.
취약한 요청/응답 예시
PATCH /api/users/me HTTP/1.1
Host: api.example.com
Authorization: Bearer user-token
Content-Type: application/json
{
"nickname": "neo",
"role": "ADMIN",
"point": 999999,
"emailVerified": true
}@PatchMapping("/api/users/me")
public UserResponse update(@RequestBody User user, Authentication authentication) {
User current = userService.findByLoginId(authentication.getName());
current.updateFrom(user);
return UserResponse.from(userRepository.save(current));
}HTTP/1.1 200 OK
Content-Type: application/json
{"id":1004,"nickname":"neo","role":"ADMIN","point":999999,"emailVerified":true}문제는 요청 객체가 domain entity이고, entity update 메서드가 사용자가 바꿀 수 있는 필드와 시스템만 바꿀 수 있는 필드를 구분하지 않는다는 점이다.
주제별 핵심 판단
- 요청 DTO는 endpoint별 allowlist다.
- 사용자용 DTO와 관리자용 DTO는 분리한다.
- entity를 request body로 직접 받지 않는다.
- domain method는 의미 있는 변경만 노출하고 setter를 열어 두지 않는다.
- unknown field를 조용히 무시할지 거부할지 정책을 정한다. 보안 민감 API는 거부가 더 추적하기 쉽다.
- blocklist보다 allowlist가 우선이다. 새 민감 필드가 entity에 추가될 때 blocklist는 쉽게 누락된다.
- 테스트에는 화면에 없는 필드를 직접 넣어야 한다.
개선된 요청/응답 예시
public record MyProfileUpdateRequest(
@NotBlank
@Size(max = 30)
String nickname
) {}@PatchMapping("/api/users/me")
public UserResponse updateMe(@Valid @RequestBody MyProfileUpdateRequest request,
Authentication authentication) {
User user = userService.findByLoginId(authentication.getName());
user.changeNickname(request.nickname());
return UserResponse.from(userRepository.save(user));
}@PatchMapping("/api/admin/users/{userId}/role")
@PreAuthorize("hasRole('ADMIN')")
public UserResponse changeRole(@PathVariable long userId,
@Valid @RequestBody AdminRoleChangeRequest request) {
return UserResponse.from(adminUserService.changeRole(userId, request.role()));
}사용자 프로필 수정과 관리자 권한 변경은 endpoint, DTO, service method를 분리한다. role은 사용자 프로필 수정 DTO에 존재하지 않는다.
방어 설계
- API별로 사용자가 직접 바꿀 수 있는 필드 목록을 먼저 적는다.
- 요청 DTO에는 그 목록만 넣는다.
- entity에는 업무 의미가 있는 method만 공개하고 무분별한 setter를 줄인다.
- 관리자 작업은 별도 command, 별도 service, 별도 audit log를 사용한다.
- Jackson이나 data binding이 unknown field를 어떻게 처리하는지 정책을 정한다.
- 민감 필드 추가 시 API DTO와 테스트를 함께 리뷰한다.
- mapper는 DTO 필드를 entity에 복사할 때 명시적으로 mapping한다.
- partial update는 JSON Merge Patch를 쓰더라도 허용 필드 검사를 먼저 수행한다.
Spring/HTTP 예시
@Configuration
public class JacksonSecurityConfig {
@Bean
Jackson2ObjectMapperBuilderCustomizer rejectUnknownFields() {
return builder -> builder.featuresToEnable(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
);
}
}@RestControllerAdvice
public class UnknownFieldExceptionHandler {
@ExceptionHandler(UnrecognizedPropertyException.class)
ResponseEntity<ApiError> handle(UnrecognizedPropertyException ex) {
return ResponseEntity.badRequest().body(
new ApiError("UNKNOWN_FIELD", "허용되지 않은 요청 필드가 있습니다.")
);
}
}전역 unknown field 거부는 호환성 영향이 크다. 기존 API에 적용하기 어렵다면 보안 민감 DTO부터 @JsonIgnoreProperties(ignoreUnknown = false)와 테스트로 적용 범위를 좁힌다.
운영 로그와 감사 지점
level=WARN event=mass_assignment_attempt result=blocked principal=member:1004 endpoint=PATCH /api/users/me unexpectedFields=role,point,emailVerified traceId=5a71ce02level=INFO event=user_role_changed result=allowed actor=admin:7 target=member:1004 oldRole=USER newRole=ADMIN reason=ticket-8291 traceId=5a71ce02- 예상 밖 필드 이름은 남기되 값 원문은 남기지 않는다.
- 민감 필드 변경은 일반 update log가 아니라 별도 감사 이벤트로 남긴다.
- 사용자 self-service endpoint에서
role,isAdmin,ownerId같은 필드 시도가 반복되면 공격으로 본다. - 관리자 변경 로그에는 actor, target, old value, new value, reason을 분리해 남긴다.
- DTO 변경 PR에는 보안 리뷰 흔적이 남아야 한다.
실전 판단 기준
- request body 타입이 entity이면 먼저 의심한다.
- DTO에
role,status,point,ownerId,price,emailVerified가 있으면 누가 바꿀 수 있는 필드인지 확인한다. - unknown field를 무시하면 공격 시도를 로그로 놓칠 수 있다.
- blocklist는 새 필드가 추가될 때 깨진다.
BeanUtils.copyProperties, ModelMapper 자동 mapping은 민감 필드 복사를 숨길 수 있다.- admin API와 user API가 같은 update DTO를 공유하면 권한 경계가 흐려진다.
테스트 포인트
- 사용자 프로필 수정 요청에
role,isAdmin,point,emailVerified를 넣어 본다. - 주문 수정 요청에
price,ownerId,paymentStatus를 넣어 본다. - unknown field가 400으로 거부되거나 최소한 보안 로그로 남는지 확인한다.
- 관리자용 DTO가 사용자 endpoint에서 재사용되지 않는지 확인한다.
- entity에 새 민감 필드가 추가될 때 기존 DTO mapping 테스트가 실패하는지 확인한다.
- partial update API에서 허용 필드 외 변경이 거부되는지 확인한다.
위험 신호!
@RequestBody User,@RequestBody Order처럼 entity를 직접 받는다.- DTO가 화면 필드보다 훨씬 많은 필드를 포함한다.
copyProperties가 source와 target 전체를 자동 복사한다.- unknown field 정책이 문서화되어 있지 않다.
- 사용자용 endpoint와 관리자용 endpoint가 같은 DTO를 쓴다.
- 권한, 소유자, 결제 상태 변경이 일반 profile update 로그에 섞인다.
확인 질문
확인 질문
- Mass Assignment의 본질은 무엇인가?
- 공격자가 보낸 예상 밖 필드가 자동 바인딩을 통해 민감한 객체 상태를 바꾸는 것이다.
- DTO가 방어가 되려면 어떤 조건이 필요한가?
- 해당 endpoint에서 사용자가 수정할 수 있는 필드만 포함하는 allowlist여야 한다.
- 민감 필드 변경은 어떻게 다뤄야 하는가?
- 별도 endpoint, 별도 권한 검사, 별도 service method, 별도 감사 로그로 다뤄야 한다.