Spring MVC에서 타인 리소스 조회 시 IDOR 취약점 막는 법
사내 시스템을 개발하다 보면 "A라는 관리자가 자신이 담당하는 B의 정보를 조회한다"는 기능을 종종 만들게 된다. 이 구조는 구현이 간단해 보이지만, 잘못 짜면 누구나 URL 파라미터 하나를 바꿔서 타인의 데이터를 열람할 수 있는 IDOR(Insecure Direct Object Reference) 취약점이 생긴다.
이 글은 실제 업무에서 마주친 케이스를 바탕으로, Spring MVC에서 이 취약점이 어떤 구조에서 발생하고 어떻게 방어하는지 정리한다.
IDOR가 뭔데 왜 위험한가
IDOR는 서버가 요청에 담긴 ID를 권한 검증 없이 그대로 쿼리에 넣을 때 발생한다.
GET /sign/budgetMemberDetail?mbrNo=200
서버가 이 mbrNo=200을 세션 확인 없이 그냥 DB 쿼리에 넣으면, 200번 회원과 아무 관계도 없는 사람도 해당 데이터를 볼 수 있다. URL 숫자 하나만 바꾸면 된다.
이 구조가 생기는 전형적인 시나리오
기존 페이지: 로그인한 본인의 예산을 보는 화면. 세션에서 mbrNo를 꺼내 쓰므로 검증 이슈가 없다.
// 기존 컨트롤러 — 문제 없음
budDto.setMbrNo(login.getMbrNo()); // 세션에서 꺼내므로 조작 불가
신규 요구사항: 관리자가 자신의 승인 대상자 예산을 조회하는 화면. 대상자 mbrNo를 파라미터로 받아야 한다.
여기서 기존 로직을 그대로 재사용하면서 파라미터만 추가하면 문제가 생긴다.
// 위험한 구현 — mbrNo를 검증 없이 쿼리에 바로 사용
budDto.setMbrNo(request.getParameter("mbrNo")); // ← 누구든 바꿀 수 있음
방어 구조
핵심은 단순하다: 파라미터로 받은 ID를 쿼리에 쓰기 전에, 로그인한 사람과 해당 ID 사이의 관계를 DB에서 확인한다.
1. 페이지 컨트롤러
@RequestMapping("budgetMemberDetail")
public ModelAndView budgetMemberDetail(
@RequestParam String mbrNo,
@Valid Budget budDto, ...) throws Exception {
ModelAndView mv = new ModelAndView();
Login login = SessionUtil.getCurrentMember();
// 검증 — 담당 대상자가 아니면 목록 페이지로 돌려보냄
if (!signService.isMyApprovalTarget(login.getMbrNo(), mbrNo)) {
mv.setViewName("redirect:/sign/targetList");
return mv;
}
budDto.setMbrNo(mbrNo); // 검증 통과 후에만 파라미터 사용
budDto.setCmpyNo(login.getCmpyNo());
// 이하 정상 조회 로직...
}
2. Service 검증 메서드
public boolean isMyApprovalTarget(String mgrMbrNo, String tgtMbrNo) {
// 관리자 mgrMbrNo의 담당 대상자 목록에 tgtMbrNo가 있는지 확인
return signMapper.countMyApprovalTarget(mgrMbrNo, tgtMbrNo) > 0;
}
SQL은 mgrMbrNo와 tgtMbrNo 두 조건을 모두 걸어서 둘 사이의 관계 레코드 수를 반환하면 된다.
AJAX 엔드포인트도 반드시 분리해야 한다
페이지 진입만 막으면 된다고 생각하면 오산이다. AJAX를 직접 호출하면 페이지 접근 검증을 우회할 수 있다.
기존 AJAX 엔드포인트를 재사용하면 안 되는 이유
기존 AJAX 컨트롤러를 보니 내부에서 세션값으로 mbrNo를 강제 세팅하고 있었다.
// 기존 budgetSearch — 파라미터 mbrNo를 무시하고 세션으로 덮어씀
budDto.setMbrNo(login.getMbrNo()); // 파라미터로 넘겨도 여기서 덮어쓰임
form serialize로 mbrNo를 파라미터에 담아 보내도 서버에서 조용히 무시된다. 기존 엔드포인트를 그대로 쓰면 신규 기능이 동작 자체를 안 한다.
신규 AJAX 엔드포인트
세션 세팅 라인만 빠진 별도 엔드포인트를 추가한다. 기존 코드는 건드리지 않는다.
@RequestMapping(value = "budgetSearchByTarget", method = RequestMethod.GET)
public String budgetSearchByTarget(Model model, @Valid Budget budDto,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
HttpServletResponse response) throws Exception {
try {
Login login = SessionUtil.getCurrentMember();
// 여기서도 동일하게 검증
if (!signService.isMyApprovalTarget(login.getMbrNo(), budDto.getMbrNo())) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return null;
}
budDto.setCmpyNo(login.getCmpyNo());
PageHelper.startPage(pageNum, pageSize);
PageInfo<Budget> pageInfo = PageInfo.of(signService.selectBudgetList(budDto));
model.addAttribute("pg", pageInfo);
} catch (Exception e) {
throw new Exception("대상자 예산 조회 오류");
}
return "page/sign/budgetMemberDetail :: #budgetSearch";
}
프론트에서 403 처리
AJAX 컨트롤러는 String을 반환하므로 redirect:가 동작하지 않는다. 서버에서 403을 내려주고 프론트에서 처리하는 것이 깔끔하다.
$.ajax({
url : "/sign/budgetSearchByTarget",
type : "get",
data : $("#budgetForm").serialize() // hidden input의 mbrNo 포함
}).done(function(result) {
$("#budgetSearch").replaceWith(result);
}).fail(function(xhr) {
if (xhr.status === 403) {
location.href = "/sign/targetList";
}
});
HTML에 mbrNo hidden input을 반드시 넣어야 serialize에 포함된다.
<input type="hidden" id="mbrNo" name="mbrNo" th:value="${mbrNo}" />
정리
| 포인트 | 내용 |
|---|---|
| IDOR 발생 조건 | 파라미터 ID를 권한 검증 없이 쿼리에 사용 |
| 방어 위치 | 페이지 컨트롤러 진입부 + AJAX 엔드포인트 모두 |
| 검증 방법 | 세션 관리자 ID + 파라미터 대상자 ID → DB 관계 확인 |
| AJAX 처리 | String 반환 컨트롤러는 redirect 불가 → 403 반환 + 프론트 리다이렉트 |
| 엔드포인트 분리 | 기존 코드에 세션 세팅이 있으면 신규 엔드포인트를 추가해야 동작함 |
세션 기반으로 본인 데이터만 보던 화면을 관리자용으로 확장할 때, 파라미터 하나 추가했다고 끝난 게 아니다. 그 파라미터가 조용히 무시되거나, 검증 없이 통과되거나 — 둘 다 흔한 실수다.