← 개발일지

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은 mgrMbrNotgtMbrNo 두 조건을 모두 걸어서 둘 사이의 관계 레코드 수를 반환하면 된다.


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 반환 + 프론트 리다이렉트 | | 엔드포인트 분리 | 기존 코드에 세션 세팅이 있으면 신규 엔드포인트를 추가해야 동작함 |

세션 기반으로 본인 데이터만 보던 화면을 관리자용으로 확장할 때, 파라미터 하나 추가했다고 끝난 게 아니다. 그 파라미터가 조용히 무시되거나, 검증 없이 통과되거나 — 둘 다 흔한 실수다.