IDOR 취약점이란? 주문·예약 API로 보는 원인과 방어법

IDOR 취약점이 주문 조회·예약 취소·파일 다운로드 API에서 생기는 원리를 취약한 코드와 고친 코드로 비교하고, UUID만으로 부족한 이유와 수동 점검 체크리스트까지 정리했습니다.

보안 점검 의뢰 팀 13분 분량

/api/orders/1024를 /api/orders/1025로 바꿨더니 다른 고객의 이름, 주소, 결제 금액이 보인다. IDOR(Insecure Direct Object Reference, 안전하지 않은 직접 객체 참조) 취약점은 이런 경우를 말합니다. 서버가 "이 사람이 로그인했는가"는 확인했지만 "이 주문이 이 사람 것인가"는 확인하지 않았기 때문에 생깁니다. OWASP는 이를 객체 수준 인가(Object Level Authorization)가 빠진 문제로 봅니다. API 보안 Top 10 2023에서는 같은 문제를 BOLA라는 이름으로 1위(API1)에 올렸습니다.

해결책도 원인만큼 단순합니다. 객체를 읽거나 바꾸는 모든 요청에서, 세션에서 꺼낸 사용자가 그 객체에 권한이 있는지 서버가 확인하면 됩니다. ID를 UUID로 바꾸거나 프론트엔드에서 버튼을 숨기는 것은 이 확인을 대신하지 못합니다.

이 글에서는 쇼핑몰과 예약 서비스에서 자주 쓰는 API 세 가지(주문 조회, 예약 취소, 파일 다운로드)를 취약한 코드와 고친 코드로 나란히 놓고 비교합니다. 이어서 UUID만으로 부족한 이유, 자동 스캐너가 IDOR을 잘 잡지 못하는 이유, 개발팀이 직접 해볼 수 있는 점검 방법을 정리합니다. 예시 코드는 모두 가상의 Node.js(Express) 코드입니다.

IDOR은 인증이 아니라 인가의 문제다

OWASP IDOR 방지 치트시트는 IDOR이 생기는 조건을 세 가지로 정리합니다.

  1. 객체: 주문, 예약, 회원 프로필, 첨부파일 같은 데이터
  2. 참조값: 그 객체를 가리키는 ID, UUID, 주문번호, 파일명, 슬러그
  3. 객체 수준 인가 검증의 부재

대부분의 팀은 인증(로그인 여부)은 미들웨어로 잘 막아 둡니다. 구멍은 그다음 단계에서 생깁니다. 클라이언트가 보낸 ID로 DB를 그대로 조회하는 순간, 그 ID를 바꿀 수 있는 사람은 누구든 남의 데이터에 닿을 수 있습니다. OWASP Top 10 2025에서도 접근 통제 실패(Broken Access Control)는 계속 1위이고, OWASP는 테스트한 애플리케이션 모두에서 어떤 형태로든 접근 통제 결함이 발견됐다고 밝혔습니다.

참조값이 들어오는 위치는 URL 경로만이 아닙니다. 쿼리스트링, 요청 본문, 헤더, 숨겨진 폼 필드도 모두 해당합니다.

패턴 1: 주문 조회 API

가장 흔한 형태입니다. 로그인 미들웨어는 붙어 있는데 조회 조건에 소유자가 없습니다.

// 취약: 로그인만 확인하고 주문의 주인은 확인하지 않는다
app.get('/api/orders/:orderId', requireLogin, async (req, res) => {
  const order = await db.order.findUnique({
    where: { id: Number(req.params.orderId) },
  });
  if (!order) return res.status(404).json({ message: 'Not found' });
  res.json(order);
});
// 수정: 조회 조건 자체에 세션 사용자를 넣어 범위를 좁힌다
app.get('/api/orders/:orderId', requireLogin, async (req, res) => {
  const order = await db.order.findFirst({
    where: {
      id: Number(req.params.orderId),
      userId: req.user.id, // 클라이언트 값이 아니라 세션에서 꺼낸 값
    },
  });
  // 남의 주문이든 없는 주문이든 같은 응답을 준다
  if (!order) return res.status(404).json({ message: 'Not found' });
  res.json(order);
});

"조회한 다음 if (order.userId !== req.user.id)로 검사"하는 방식도 동작은 합니다. 그래도 쿼리 단계에서 범위를 좁히는 방식(scoped query)을 권합니다. 검사 줄을 깜빡 빠뜨려도 남의 데이터가 애초에 조회되지 않기 때문입니다. Django REST Framework라면 get_queryset()에서 Order.objects.filter(user=self.request.user)로 시작하는 식으로, 프레임워크마다 같은 패턴을 쓸 수 있습니다.

패턴 2: 예약 취소 API — 쓰기 작업이 더 위험하다

조회보다 피해가 큰 쪽은 변경과 삭제입니다. 예약 서비스에서 자주 보이는 실수는 검사는 하는데, 검사에 쓰는 값을 클라이언트가 보내는 경우입니다.

// 취약: 비교에 쓰는 userId가 요청 본문에서 온다
app.post('/api/reservations/cancel', requireLogin, async (req, res) => {
  const { reservationId, userId } = req.body;
  const reservation = await db.reservation.findUnique({
    where: { id: reservationId },
  });
  if (reservation.userId !== userId) {   // 공격자가 userId도 같이 바꾸면 통과
    return res.status(403).end();
  }
  await db.reservation.update({
    where: { id: reservationId },
    data: { status: 'CANCELLED' },
  });
  res.json({ ok: true });
});
// 수정: 사용자는 세션에서 정하고, 정책은 한 함수로 모은다
function canCancelReservation(user, reservation) {
  if (reservation.userId === user.id) return true;              // 본인 예약
  if (user.role === 'STORE_MANAGER'
      && reservation.storeId === user.storeId) return true;     // 자기 매장 예약
  return false;
}

app.post('/api/reservations/:id/cancel', requireLogin, async (req, res) => {
  const reservation = await db.reservation.findUnique({
    where: { id: req.params.id },
  });
  if (!reservation || !canCancelReservation(req.user, reservation)) {
    return res.status(404).json({ message: 'Not found' });
  }
  await db.reservation.update({
    where: { id: reservation.id },
    data: { status: 'CANCELLED', cancelledBy: req.user.id },
  });
  res.json({ ok: true });
});

여기서 짚을 점은 세 가지입니다.

  • "누가 요청했는가"는 언제나 세션이나 검증된 토큰에서 가져옵니다. 요청 본문의 userId, memberNo, storeId는 믿지 않습니다.
  • 매장 관리자, 공동 예약자처럼 권한 규칙이 여러 개면 라우트마다 if문을 흩어 두지 말고 정책 함수 하나로 모읍니다. OWASP Top 10도 "접근 통제는 한 번 구현해 애플리케이션 전체에서 재사용하라"고 권합니다.
  • 누가 취소했는지(cancelledBy)를 남겨 두면 나중에 이상 징후를 추적할 수 있습니다.

패턴 3: 파일 다운로드 — 영수증, 증빙서류, 첨부파일

PortSwigger 문서에는 /static/12144.txt처럼 번호 순서대로 저장된 파일을 숫자만 바꿔 받아 가는 예가 나옵니다. 쇼핑몰의 영수증 PDF, 예약 서비스의 신분증 사본, 문의 게시판 첨부파일이 같은 구조라면 위험합니다.

// 취약: 파일명을 그대로 받아 디스크에서 내려준다 (경로 조작 위험도 함께 있다)
app.get('/api/files/download', requireLogin, (req, res) => {
  res.download(path.join(UPLOAD_DIR, req.query.name)); // receipt_10231.pdf
});
// 수정: 파일 메타데이터로 소유 관계를 확인한 뒤, 짧게 유효한 URL만 발급한다
app.get('/api/files/:fileId', requireLogin, async (req, res) => {
  const file = await db.file.findFirst({
    where: { id: req.params.fileId, ownerId: req.user.id },
  });
  if (!file) return res.status(404).json({ message: 'Not found' });

  const url = await storage.createSignedUrl(file.storageKey, { expiresIn: 60 });
  res.redirect(url);
});

클라우드 스토리지의 서명된 URL(presigned URL)은 편리하지만, 인가 검사를 통과한 다음에 발급해야 의미가 있습니다. 버킷을 공개로 열어 두고 키 이름만 어렵게 만드는 방식은 다음 절에서 다룰 "UUID만 쓰는 방식"과 같은 한계를 가집니다.

ID를 UUID로 바꾸면 끝일까

OWASP 치트시트와 API Security Top 10 모두 추측하기 어려운 무작위 ID를 권장합니다. 다만 둘 다 이를 인가 검사를 대신하는 수단이 아니라 보조 수단(심층 방어)으로 설명합니다. 실무에서 UUID만으로 부족한 이유는 다음과 같습니다.

  • ID는 생각보다 많은 곳에서 노출됩니다. 목록 API 응답, 리뷰나 공동 구매 화면, 공유 링크, 이메일 속 링크, 브라우저 기록, 서버 로그와 분석 도구에 남은 URL 등이 그렇습니다. 맞히기 어려워도 다른 경로로 알아낼 수 있습니다.
  • 예전에 정당하게 얻은 ID가 남아 있습니다. 탈퇴한 회원, 권한을 잃은 직원, 예약에서 빠진 동행자도 예전 ID를 알고 있습니다.
  • UUID 버전에 따라 성질이 다릅니다. v1처럼 시간이나 장비 정보로 만드는 방식은 무작위성이 낮습니다. 비밀 값처럼 쓰려면 적어도 무작위 기반 버전을 써야 하고, 그래도 "비밀"로 취급해서는 안 됩니다.
  • ID 암호화도 대안이 아닙니다. OWASP는 식별자를 안전하게 암호화하기가 어렵다는 이유로 권장하지 않습니다.

판단 기준은 이렇습니다. "이 ID가 모든 사용자에게 공개돼도 안전한가?"라는 질문에 "예"라고 답할 수 있어야 합니다. 그렇게 답할 수 있다면 UUID는 대량 열거를 어렵게 해 주는 좋은 보조 장치입니다.

자동 스캐너가 IDOR을 잘 못 찾는 이유

DAST 스캐너는 SQL 인젝션이나 XSS처럼 응답에 뚜렷한 흔적이 남는 문제는 잘 찾습니다. IDOR은 성격이 다릅니다.

  • 정상 응답과 취약한 응답이 똑같이 생겼습니다. 200 OK에 주문 JSON이 돌아와도, 그 주문이 요청한 사람 것인지 스캐너는 알 수 없습니다.
  • 판단에 업무 맥락이 필요합니다. "매장 관리자는 자기 매장 예약만 취소할 수 있다" 같은 규칙은 코드나 기획 문서에만 있고 HTTP 트래픽에는 드러나지 않습니다.
  • 계정이 최소 두 개 필요합니다. OWASP 테스트 가이드(WSTG)도 서로 다른 객체를 가진 사용자를 두 명 이상 두고 교차 접근을 시험하라고 안내합니다. 일반 스캔 설정으로는 이런 시나리오가 잘 나오지 않습니다.
  • 쓰기 작업은 부작용이 있습니다. 취소, 삭제, 환불 API를 마구 호출하면 데이터가 망가지기 때문에 스캐너는 보통 이런 요청을 조심스럽게 다루거나 건너뜁니다.

그래서 IDOR은 코드 리뷰와 수동 점검으로 찾는 편이 현실적입니다. 스캐너가 아무것도 찾지 못했다는 결과가 인가가 잘 돼 있다는 뜻은 아닙니다.

개발팀을 위한 실무 체크리스트

설계·구현 단계

  • 객체 ID를 받는 모든 엔드포인트(조회, 수정, 삭제, 내보내기, 관리자 기능)에 객체 수준 인가가 있다
  • 요청한 사용자는 세션이나 검증된 토큰에서만 가져오고, 본문·쿼리의 사용자 ID는 쓰지 않는다
  • 기본 조회 패턴이 where id = ? AND owner_id = ? 형태로 범위가 좁혀져 있다
  • 역할별 규칙은 정책 함수나 미들웨어 한 곳에 모여 있다
  • 권한이 없을 때와 객체가 없을 때 응답을 일관되게 정했다
  • 파일은 메타데이터로 소유를 확인한 뒤 짧게 유효한 URL로만 제공한다
  • 인가 실패 로그를 남기고, 짧은 시간에 실패가 몰리면 알림이 온다

테스트 단계

인가 테스트를 CI에 넣어 두면 이후 기능을 추가하다가 검사가 빠지는 회귀를 막을 수 있습니다.

test('다른 사용자의 주문은 조회할 수 없다', async () => {
  const orderOfA = await createOrder({ userId: userA.id });

  const res = await request(app)
    .get(`/api/orders/${orderOfA.id}`)
    .set('Cookie', await loginAs(userB));

  expect(res.status).toBe(404);
});

수동 점검 방법 (우리 서비스 스테이징에서)

  1. 테스트 계정 A, B(가능하면 역할이 다른 C까지)를 만듭니다.
  2. A로 서비스를 사용하면서 요청에 들어가는 객체 ID를 모두 기록합니다. 경로, 쿼리, 본문, 헤더를 빠짐없이 봅니다.
  3. B의 세션으로 같은 요청을 다시 보내, 거부되는지 확인합니다.
  4. 읽기만 보지 말고 수정, 삭제, 취소, 내보내기, 일괄 처리 API까지 모두 확인합니다.
  5. 모바일 앱 전용 API, 구버전 API(/v1/), 관리자 API처럼 잊기 쉬운 경로도 목록에 넣습니다.

자주 묻는 질문

프론트엔드에서 남의 주문 링크를 안 보여주면 되지 않나요?

안 됩니다. 화면에서 숨기는 것은 사용성의 문제일 뿐이고, API는 누구나 직접 호출할 수 있습니다. 인가는 반드시 서버에서 해야 합니다.

권한이 없을 때 403과 404 중 무엇을 줘야 하나요?

자원이 있다는 사실 자체가 민감하다면(예: 특정 주문번호가 실제로 있는지) 404로 통일하는 편이 안전합니다. OWASP 치트시트도 이런 경우 "없음"과 "권한 없음"에 같은 응답을 주라고 권합니다. 내부 관리 도구라면 디버깅을 위해 403을 써도 됩니다. 어느 쪽이든 팀 안에서 일관되게 쓰는 것이 중요합니다.

B2B SaaS처럼 회사(테넌트) 단위로 데이터를 나누면 IDOR은 신경 안 써도 되나요?

오히려 더 신경 써야 합니다. 고객사 A의 직원이 고객사 B의 데이터에 닿는 테넌트 간 IDOR이 생길 수 있습니다. 모든 쿼리에 tenant_id 조건을 강제하는 계층(ORM 기본 스코프, DB 행 수준 보안 등)을 두고, 같은 테넌트 안에서의 역할 구분도 따로 확인하세요.

정리

  • IDOR은 로그인 확인(인증)은 했지만 객체 소유 확인(인가)을 빠뜨려서 생기며, OWASP가 API 위험 1위로 꼽은 문제입니다.
  • 방어의 핵심은 세션 사용자를 기준으로 범위를 좁힌 쿼리와, 한 곳에 모은 인가 정책입니다. UUID는 보조 수단일 뿐입니다.
  • 자동 스캐너는 업무 맥락을 모르기 때문에 IDOR을 놓치기 쉽습니다. 계정 두 개로 하는 교차 점검과 CI 인가 테스트를 함께 하세요.

직접 점검할 여력이 없다면 무료 분석 신청으로 우리 서비스 API의 인가 구조를 점검받아 볼 수도 있습니다.

참고 자료

함께 읽으면 좋은 글