IDOR·BOLA는 로그인한 사용자가 주소의 번호 하나만 바꿔 남의 주문, 남의 신청서, 남의 계좌를 읽거나 고치는 취약점이다. 인증과 역할 검사를 모두 통과한 요청에서 생기기 때문에 Spring Security 설정만으로는 막히지 않고, 객체를 꺼내는 코드마다 "이 사용자의 것인가"를 물어야 한다. 이 글은 Spring Boot API에서 객체 수준 인가를 빠뜨리지 않도록 조회 방식, 정책 빈, 쓰기 요청, 테스트를 어떻게 설계하는지 정리한 것이다.

IDOR와 BOLA, 한눈에
IDOR(Insecure Direct Object Reference)는 요청에 들어온 식별자를 그대로 믿고 객체를 돌려주는 결함이다. OWASP Top 10에서는 Broken Access Control(A01:2021)에 묶여 있다. API 쪽에서는 같은 결함을 BOLA(Broken Object Level Authorization)라고 부르고, OWASP API Security Top 10의 첫 항목 API1:2023으로 올라 있다. 이름은 다르지만 질문은 하나다. "이 객체를 이 사용자가 다뤄도 되는가."
요청 하나에는 세 가지 인가 질문이 있다. 서로 다른 층에서 답해야 한다.
| 질문 | 예시 | 답하는 곳 | 실패 응답 |
|---|---|---|---|
| 이 기능을 쓸 수 있나 | 관리자 API, order:write 스코프 |
필터 체인, @PreAuthorize |
403 |
| 이 객체가 보이나 | 주문 1024가 내 주문인가 | 사용자 범위로 좁힌 조회 | 404 |
| 이 객체에 이 행위를 해도 되나 | 상담원이 주문을 볼 수는 있지만 취소는 못 함 | 정책 빈 | 403 |
첫 번째 질문은 OWASP가 API5:2023(BFLA)로 따로 다루는 기능 수준 인가다. BOLA는 두 번째와 세 번째 질문을 빠뜨렸을 때 생긴다.

객체 ID가 들어오는 입구부터 목록으로 만든다
BOLA는 경로 변수에서만 생기지 않는다. 사용자가 값을 바꿀 수 있는 곳이면 어디든 객체 ID가 들어온다. 점검은 이 입구 목록을 만드는 데서 시작한다.
- 경로 변수:
/orders/{orderId},/users/{userId}/addresses/{addressId} - 쿼리 파라미터:
?applicationId=77,?accountNo=… - 요청 본문:
{"orderId": 1024, "couponId": 9}처럼 수정 대상이나 연결 대상을 본문으로 받는 경우 - 일괄 처리:
{"ids": [1, 2, 3]}. 하나씩 확인하지 않고findAllById로 한 번에 처리하기 쉽다 - 하위 리소스: 부모는 내 것인데 자식 ID는 남의 부모에 속한 경우
- 파일 키·내보내기 작업 ID: 첨부, 보고서 생성 결과, 비동기 작업 상태 조회
컨트롤러 목록을 뽑아 표로 만들고, 행마다 "ID 출처 / 소유 기준(사용자·부서·기관) / 확인 위치"를 채운다. 확인 위치 칸이 비는 행이 곧 취약점 후보다. 순번 대신 UUID를 쓰면 추측이 어려워질 뿐, 목록 응답·로그·공유 링크로 ID가 새면 그대로 뚫린다. UUID는 인가가 아니다.
1층: 조회 자체를 사용자 범위로 좁힌다
가장 확실한 방어는 남의 객체를 아예 꺼내지 않는 것이다. findById(id)로 꺼낸 뒤 소유자를 비교하면, 비교 한 줄을 잊은 엔드포인트가 반드시 생긴다. 조회 조건에 소유 기준을 넣으면 잊을 틈이 줄어든다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// 서비스 계층에서는 id만 받는 조회를 쓰지 않는다
Optional<Order> findByIdAndOwnerId(Long id, String ownerId);
Page<Order> findByOwnerId(String ownerId, Pageable pageable);
}
@Service
@RequiredArgsConstructor
public class OrderQueryService {
private final OrderRepository orders;
@Transactional(readOnly = true)
public OrderDto get(Long orderId, Jwt jwt) {
return orders.findByIdAndOwnerId(orderId, jwt.getSubject()) // 소유자는 토큰에서만 꺼낸다
.map(OrderDto::from)
.orElseThrow(() -> new NotFoundException("order"));
}
}
이 방식의 장점은 "없는 주문"과 "남의 주문"이 같은 빈 결과가 된다는 점이다. 둘 다 404로 응답하면 공격자는 번호를 바꿔 가며 어떤 주문이 존재하는지조차 알 수 없다. 소유 기준이 사용자 하나가 아니라 부서나 기관이면 조건이 dept_id IN (…)이 될 뿐 구조는 같다.
목록 API에서 @PostFilter로 걸러내는 방식은 피한다. 전체를 읽은 뒤 메모리에서 거르므로 느리고, 페이지 크기와 총건수가 어긋난다. 목록도 조회 조건으로 좁힌다.
여러 기관이 한 DB를 쓰는 다중 테넌트 구조라면, 테넌트 ID를 요청 헤더가 아니라 토큰 클레임에서 꺼내고, Hibernate 6의 @TenantId나 필터처럼 조회 전체에 조건을 거는 장치를 함께 쓴다. 개발자가 조건을 쓰지 않아도 붙는 장치가 있어야 신규 화면에서 빠지지 않는다.
2층: 행위 권한은 정책 빈 하나에 모은다
보이는 객체라고 모든 행위가 허용되지는 않는다. 상담원은 고객 주문을 조회할 수 있지만 취소는 본인이나 관리자만 한다. 배송이 시작된 주문은 본인도 취소할 수 없다. 이런 규칙은 객체 상태까지 봐야 하므로 URL 규칙으로 표현할 수 없다.
규칙은 정책 빈 하나에 모으고, 조회한 객체를 넘겨 판단한다.
@Component
public class OrderPolicy {
public void checkCancel(Order order, Jwt jwt) {
boolean owner = order.getOwnerId().equals(jwt.getSubject());
boolean admin = hasScope(jwt, "order:admin");
if (!(owner || admin) || !order.isCancellable()) {
throw new AccessDeniedException("order.cancel"); // 이미 보이는 객체이므로 403
}
}
}
@Transactional
public void cancel(Long orderId, Jwt jwt) {
Order order = orderFinder.visibleTo(orderId, jwt); // 1층: 없거나 남의 것이면 404
orderPolicy.checkCancel(order, jwt); // 2층: 행위 권한
order.cancel();
}
@PreAuthorize("@orderPolicy.canCancel(#orderId, authentication)")처럼 메서드 보안으로 거는 방식도 된다. 다만 정책이 ID만 받아 거절하면 남의 주문에 403이 나가 존재가 드러나고, 정책 안에서 한 번, 서비스에서 또 한 번 조회하게 된다. @PreAuthorize는 스코프·역할 같은 기능 수준 검사에 쓰고, 객체 수준 판단은 조회한 객체를 받는 정책 빈에서 하면 응답 코드와 조회 횟수가 함께 정리된다. @PostAuthorize는 메서드를 먼저 실행하므로 상태를 바꾸는 메서드에 붙이지 않는다.
쓰기 요청과 하위 리소스에서 새는 곳
조회보다 쓰기에서 더 자주 뚫린다. 수정 화면은 남의 객체를 읽지 못하게 막아 두고, 저장 API는 본문에 든 ID를 그대로 쓰는 식이다.
본문 ID 바꿔치기: PUT /orders/1024 요청 본문에 "orderId": 2048이 들어 있으면 어느 쪽을 쓰는가. 대상 ID는 경로 하나에서만 받고, 본문 DTO에는 넣지 않는다.
소유자 필드 주입: 등록 요청 본문의 ownerId, deptId를 그대로 엔티티에 복사하면 남의 이름으로 글을 쓸 수 있다. 소유자는 서버가 토큰에서 정한다. 요청 DTO에 소유자 필드를 두지 않는 것이 가장 확실하다. OWASP는 이런 속성 단위 결함을 API3:2023으로 따로 분류한다.
연결 대상 ID: 주문에 쿠폰·배송지·첨부를 연결하는 요청은 연결 대상도 내 것인지 확인한다. 주문만 확인하고 addressId는 확인하지 않으면 남의 주소로 배송지를 바꿀 수 있다.
하위 리소스: /orders/{orderId}/items/{itemId}는 부모와 자식을 둘 다 확인한다.
Order order = orderFinder.visibleTo(orderId, jwt);
OrderItem item = itemRepository.findByIdAndOrderId(itemId, order.getId())
.orElseThrow(() -> new NotFoundException("item")); // 다른 주문의 품목이면 404
일괄 처리: ids 목록은 findAllByIdInAndOwnerId(ids, owner)로 조회한 뒤 건수가 요청한 수와 다르면 전체를 거절한다. 일부만 처리하면 어느 ID가 남의 것인지 알려 주는 셈이다.
두 사용자 테스트로 회귀를 막는다
인가 코드는 기능 테스트에서 드러나지 않는다. 본인 계정으로만 테스트하면 언제나 통과한다. 그래서 테스트에 사용자를 둘 둔다. A가 만든 객체를 B의 토큰으로 요청하고 404가 나오는지 본다.
@Test
void 다른_사용자의_주문은_404() throws Exception {
long orderId = fixture.orderOwnedBy("user-a");
mockMvc.perform(get("/api/orders/{id}", orderId)
.with(jwt().jwt(j -> j.subject("user-b"))))
.andExpect(status().isNotFound());
mockMvc.perform(post("/api/orders/{id}/cancel", orderId)
.with(jwt().jwt(j -> j.subject("user-b"))))
.andExpect(status().isNotFound());
}
jwt()는 spring-security-test의 SecurityMockMvcRequestPostProcessors에 있다. 앞에서 만든 입구 목록의 행마다 이런 테스트가 하나씩 있어야 한다. 신규 엔드포인트가 빠지지 않도록, RequestMappingHandlerMapping에서 경로 변수가 있는 핸들러를 모두 꺼내 "B 사용자 테스트가 있는 핸들러 목록"과 비교하는 테스트를 CI에 두면 목록과 코드가 어긋나는 순간 빌드가 깨진다.
운영에서는 거절을 기록한다. 사용자, 객체 종류, 요청 ID, 결과(404·403)를 남기고, 한 사용자가 짧은 시간에 연속된 번호로 404를 여러 번 받으면 알림을 보낸다. 번호를 훑는 공격은 이 패턴으로 드러난다.
방어 수단을 어디에 두나
| 방어 수단 | 위치 | 없으면 생기는 일 |
|---|---|---|
| 사용자 범위 조회 | Repository, 서비스 | 소유자 비교 한 줄을 잊은 API에서 남의 객체가 나감 |
| 404 통일 | 예외 처리기 | 번호를 바꿔 가며 존재 여부를 알아냄 |
| 정책 빈 | 서비스 계층 | 같은 규칙이 화면마다 달라지고 하나가 빠짐 |
| 소유자는 토큰에서 | 등록·수정 서비스 | 본문 필드로 남의 이름의 데이터 생성 |
| 하위·연결 대상 확인 | 서비스 | 부모만 확인하고 자식으로 우회 |
| 두 사용자 테스트 | CI | 리팩터링 뒤 조건이 빠져도 모름 |
| 거절 기록과 알림 | 로그, 모니터링 | 번호 훑기 공격을 사고 뒤에야 앎 |
자주 하는 실수
- 화면에서 버튼을 숨기고 끝낸다. 공격자는 화면이 아니라 API를 직접 호출한다.
findById뒤에 소유자를 비교한다. 비교를 빠뜨린 메서드 하나가 전체를 무너뜨린다. 조회 조건에 넣어야 잊을 수 없다.- UUID라서 안전하다고 본다. ID는 목록 응답, 로그, 공유 링크로 새어 나간다.
- 남의 객체에 403을 준다. 403은 그 객체가 존재한다고 알려 준다. 보이지 않는 객체는 404로 통일한다.
- 본인 계정으로만 테스트한다. 인가 결함은 다른 사용자의 토큰으로 요청해야 드러난다.
점검표
- 객체 ID를 받는 모든 입구(경로·쿼리·본문·일괄·하위 리소스·파일 키)가 목록에 있다
- 서비스 계층에서 소유 기준 없이 ID만으로 조회하는 메서드가 없다
- 목록 API가
@PostFilter가 아니라 조회 조건으로 범위를 좁힌다 - 없는 객체와 남의 객체가 같은 404로 응답한다
- 행위 권한 규칙이 정책 빈 한 곳에 있고 조회한 객체를 받아 판단한다
- 소유자·부서 필드를 요청 본문에서 받지 않고 토큰에서 정한다
- 하위 리소스와 연결 대상 ID도 소유를 확인한다
- 일괄 요청은 조회 건수가 요청 건수와 다르면 전체를 거절한다
- 입구 목록의 행마다 다른 사용자 토큰으로 요청하는 테스트가 있다
- 404·403 거절이 사용자·객체 종류와 함께 기록되고 연속 거절에 알림이 간다
BOLA 방어는 엔드포인트마다 소유자 비교를 잘 쓰는 일이 아니라, 남의 객체가 애초에 조회되지 않는 구조와 그것을 다른 사용자의 토큰으로 매번 확인하는 테스트를 갖추는 일이다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.