← 개발일지

Preventing IDOR in Spring MVC Parameter-Based Resource Access


When you build a feature where a manager can view a team member's data, it seems straightforward: accept a member ID as a URL parameter, run the query, return the results. But there's a vulnerability hiding in that simplicity — one that's easy to ship accidentally and hard to notice until it's too late.

This is a real case from a Spring MVC project. The feature: a budget manager views the budget status of their assigned team members. The vulnerability: without proper validation, anyone can change the ID in the URL and read someone else's data.


What IDOR Is and Why It Matters Here

IDOR stands for Insecure Direct Object Reference. It happens when a server takes a user-supplied ID and uses it directly in a database query without checking whether the requester actually has access to that object.

GET /sign/budgetMemberDetail?mbrNo=200

If the server just plugs mbrNo=200 into the query without verifying that the logged-in user has a relationship with member 200, you've got an IDOR. Changing the number in the URL is the only thing an attacker needs to do.


How This Structure Happens in Practice

The original page showed the currently logged-in user's own budget. No IDOR risk there — the member ID comes from the session, which can't be tampered with.

// Original controller — safe
budDto.setMbrNo(login.getMbrNo()); // from session, not user input

Then a new requirement comes in: managers need to view the budgets of their assigned members. The member ID now has to come from a URL parameter. This is where things go wrong if you're not careful.

// Dangerous — no validation
budDto.setMbrNo(request.getParameter("mbrNo")); // anyone can change this

The Fix: Validate the Relationship Server-Side

The fix is simple in concept: before using the parameter ID in any query, verify in the database that the logged-in user actually has a relationship with that target member.

Page Controller

@RequestMapping("budgetMemberDetail")
public ModelAndView budgetMemberDetail(
        @RequestParam String mbrNo,
        @Valid Budget budDto, ...) throws Exception {

    ModelAndView mv = new ModelAndView();
    Login login = SessionUtil.getCurrentMember();

    // Reject and redirect if the target isn't the manager's assigned member
    if (!signService.isMyApprovalTarget(login.getMbrNo(), mbrNo)) {
        mv.setViewName("redirect:/sign/targetList");
        return mv;
    }

    // Only reach here if validation passed
    budDto.setMbrNo(mbrNo);
    budDto.setCmpyNo(login.getCmpyNo());
    // ... rest of the logic
}

Service Validation

public boolean isMyApprovalTarget(String managerMbrNo, String targetMbrNo) {
    // Query checks that a relationship record exists between these two IDs
    return signMapper.countMyApprovalTarget(managerMbrNo, targetMbrNo) > 0;
}

The SQL for countMyApprovalTarget filters by both IDs and returns the count of relationship records. If it's 0, access is denied.


Don't Forget the AJAX Endpoint

If your page has an AJAX-driven search or pagination, that endpoint needs the same validation. Blocking the page entry point alone isn't enough — someone can call the AJAX endpoint directly and bypass your check.

Why You Can't Reuse the Existing Endpoint

The original AJAX controller had this:

// Original budgetSearch — quietly ignores any mbrNo parameter
budDto.setMbrNo(login.getMbrNo()); // overwrites parameter with session value

Even if you serialize the form and include mbrNo in the request, the server silently replaces it with the session user's ID. The new feature won't work at all if you try to reuse this endpoint — and modifying the original risks breaking the existing feature.

The right move: add a new endpoint. Keep the existing one untouched.

New AJAX Endpoint

@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();

        // Same validation as the page controller
        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("Target budget lookup error");
    }

    // Reuse the same Thymeleaf fragment
    return "page/sign/budgetMemberDetail :: #budgetSearch";
}

Handling the 403 on the Frontend

Spring MVC controllers that return String can't use the redirect: prefix — that only works for full page navigation. For AJAX, return a 403 and let the frontend handle the redirect.

$.ajax({
    url  : "/sign/budgetSearchByTarget",
    type : "get",
    data : $("#budgetForm").serialize() // must include mbrNo hidden input
}).done(function(result) {
    $("#budgetSearch").replaceWith(result);
}).fail(function(xhr) {
    if (xhr.status === 403) {
        location.href = "/sign/targetList";
    }
});

And make sure the Thymeleaf template includes mbrNo as a hidden input, or it won't be included in the serialized form data:

<input type="hidden" id="mbrNo" name="mbrNo" th:value="${mbrNo}" />

Summary

| Point | Details | |---|---| | IDOR trigger | Using a parameter ID in a query without authorization check | | Where to validate | Both the page controller and the AJAX endpoint | | Validation method | DB query verifying relationship between session user and target ID | | AJAX 403 handling | Return HTTP 403 from server; handle redirect in .fail() | | Endpoint strategy | Add a new endpoint rather than modifying the existing one |

When you extend a self-service screen into a manager view, the security model changes fundamentally. What was safe before (session-based ID) becomes a risk when you switch to parameter-based ID. That transition is where IDOR vulnerabilities slip in — often quietly, with no obvious bug, until someone notices the URL.