같은 IDOR 패턴이 MyCourse · Wishlist 에 그대로 남아 있던 사건 + OAuth 후 권한 재검증 — POP-SPOT v2.9
v2.7 에서 GameController IDOR 만 봉합했는데 같은 패턴이 MyCourse · Wishlist 에 그대로 남아 있었음. OAuth 후 localStorage 의 role/isPremium 의 서버 재검증, RuntimeException 3건 409 격상, 지도 마커 N+1 해결.
v2.7 에서 GameController 하나만 잡았는데, 같은 패턴이 MyCourse · Wishlist 에 또 남아 있었다. 코드 리뷰 하다가 발견. 다른 고감도 이슈 몇 개도 같이 정리한 버전.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| IDOR | 주소창/요청 본문의 id 를 서버가 그대로 신뢰해 남의 것을 조작할 수 있는 결함 |
| N+1 | "한 번 불러오고 하나씩 조회" 로 인해 쿼리가 수백 번 나가는 성능 문제 |
| 409 Conflict | HTTP 상태 코드 — "요청은 맞는데 현재 서버/외부 상태 때문에 안 됨" |
| DTO | Data Transfer Object. JPA 엔티티를 명시적으로 가공해서 클라이언트에 넘길 때 쓰는 도메인 경계 객체 |
이슈 여섯 건
| 이슈 | 위험도 | 증상 | 해결 |
|---|---|---|---|
| Wishlist IDOR | Critical | 주소창 userId 검증 없음 | 토큰 userId 와 일치 검증 |
| MyCourse IDOR | Critical | 코스 저장·조회·삭제 모두 클라이언트 userId 신뢰 | 토큰 userId 검증 + 소유자 검사 |
| OAuth 권한 위조 | High | localStorage 의 role/isPremium 을 신뢰 | 비공개 페이지 진입 시 서버 재검증 |
| RuntimeException | Medium | AI 호출 · 메일 발송 실패가 400 으로 응답 | 외부 서비스 장애로 격상 → 409 응답 |
| 지도 마커 N+1 | Medium | 팝업 전체를 메모리로 로드 | SQL WHERE 절 쿼리 재사용 |
| MyCourse 엔티티 노출 | Medium | JPA 엔티티 직접 JSON 직렬화 | DTO 로 변환 |
왜 이렇게 했음
같은 패턴은 다른 워낞에도 있는가 — v2.7 의 GameController 면 수정 후, "클라이언트 userId 를 신뢰" 하는 다른 코드가 어디 있는지 일괄 검색해야 일 하단 닫은 게 되는데, 당시는 "티켓" 이니 명확한 곳만 볼 하고 지나감. v2.9 에서 MyCourse, Wishlist 에 같은 패턴이 남아있는 걸 발견. "한 파일 고치면 같은 패턴의 다른 파일도 보자" 는 그래서 일종의 흡수 규칙처럼 쓰이게 됨.
권한은 다시 뵤다 — OAuth 로그인 끝나고 localStorage 에 저장한 role/isPremium 을 그대로 참조하면 사용자가 임의로 고쳐 admin 으로 진입할 수 있음. 비공개 페이지는 들어갑 때 서버가 한 번 더 "그게 맞아?" 검증. 추가 호출이 생기지만 권한 고정 수준을 높이는 도리.
장애 ≠ 입력 오류 — AI 호출·메일 발송이 일시적으로 안 되는 걸 사용자의 "입력이 잘못됨" (400) 으로 내보내면 사용자가 혼란스럽다. "서버 쪽의 일시적 문제" 임을 409 로 알리고 "잠시 후 다시 시도" 안내를 같이 내보낸다.
엔티티 직접 직렬화 — JPA 엔티티를 그대로 JSON 직렬화하면 양방향 관계를 따라가며 lazy loading 이 터져 JSON 이 폭발한다. 필요한 필드만 가진 DTO 로 변환하는 게 표준.
코드로는 어떻게 (필요한 부분만)
IDOR 일괄 해결 패턴.
파일: popspot-backend/src/main/java/com/popspot/controller/WishlistController.java
// v2.8 까지 — Wishlist IDOR 잠재
@GetMapping("/wishlist")
public List<Wishlist> get(@RequestParam Long userId) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ ?userId=7 쿼리 파라미터 그대로 수용
// 공격자: ?userId=7,8,9,... 다르게 타인의 위시리스트 조회 가능
return wishlistRepository.findByUserId(userId);
// ^^^^^^^^^^^^^^^^^^^^^^ 권한 검사 없이 SELECT * FROM wishlist WHERE user_id=?
// 타인의 입력만 일치하면 그대로 반환 — 대표적 IDOR
}파일: 같은 WishlistController.java (v2.9 교체 후) + 서비스 계층 자체는 popspot-backend/.../service/WishlistService.java
// v2.9 — 주소창 userId 무시, 토큰 기반 + 서비스 계층 소유자 검사
@GetMapping("/wishlist")
public List<WishlistResponseDto> get(@AuthenticationPrincipal Long tokenUserId) {
// ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// | |
// | +-- Spring Security 가 주입. 토큰 검증 필터에서 구축됨
// | 클라이언트가 조작할 수 없음·필자 서명된 JWT 안 주인 ID
// +-- DTO 로 변환된 레스폰스 (엔티티 직접 노출 아님 — LAZY 관계·내부 필드 노출 머멑·서택 어·차·단)
return wishlistService.findOwnedBy(tokenUserId);
// ^^^^^^^^^^^^^^^^^^^^^^^^ 메서드명이 도메인 원칙 설명: "소유자가 이 사용자인 wishlist 만 조회"
// 서비스 계층이 @Transactional(readOnly=true) 로 읽기 전용 설정
// wishlist.userId == tokenUserId 조건 일치 안 되면 비어있는 리스트 반환
}권한 재검증 (AuthGuard).
파일: popspot-frontend/src/components/AuthGuard.tsx
// v2.9 — AuthGuard.tsx. 비공개 페이지에 들어올 때 권한 재검증
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
// 위: 서버에 "나는 누구야?" 물음. credentials: 'include' 는 쿼키 (JWT 세션) 함께 전송
// 서버가 이 쿼키를 검증하고 실제 사용자 권한 정보를 응답 — 클라이언트가 조작 불가
.then(r => r.ok ? r.json() : null)
// ^^^^^^ ^^^^^^^^ 200 OK 면 JSON 파싱, 그외엔 null (토큰 만료·이상)
.then(user => {
// localStorage 의 role/isPremium 은 무시 — 클라이언트에서 조작할 수 있는 값이므로 근거로 쓰면 안 됨
if (!user || !allowedRoles.includes(user.role)) {
// ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// | |
// | +-- 서버가 답한 user.role 이 allowedRoles 에 들어 있는지 검사
// +-- user 가 null 이면 차단 (로그아웃·토큰 제거·세션 만료 등 모두 포함)
router.replace('/');
// 위: 권한 부족 → 루트로 돌려보냄. replace 는 이전 페이지 히스토리에 남기지 않음
}
});
}, []);
// 필요 비용: 페이지당 한 번의 추가 호출. 그러나 권한 속임수 차단이라 감수할 만한 비용400 → 409 에러 격상.
파일: popspot-backend/src/main/java/com/popspot/exception/GlobalExceptionHandler.java
// v2.9 — GlobalExceptionHandler 에 외부 서비스 장애 전용 핸들러 추가
@ExceptionHandler({ ExternalServiceException.class })
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AI 호출·메일 발송 등 외부 장애 전용 커스텀 예외
// v2.9 이전에는 RuntimeException 으로 더붙으면서 일괄 400 으로 이상
h andled
public ResponseEntity<ErrorResponse> handleExternal(ExternalServiceException e) {
return ResponseEntity.status(409).body(
// ^^^^ HTTP 409 Conflict — "서버 쪽 일시 상태 트래픽 충돌/장애"
// 400 은 클라이언트의 입력 오류 의미 — 사용자가 리트라이 해도 결과가 같음
// 409 는 "잠시 후 다시" 가 함식된 상태 코드라 재시도 유도가 명확
new ErrorResponse("잠시 후 다시 시도해 주세요."));
// 위: 메시지 포맷이 공통이라 프론트 에서 일괄 섬세·표시·재시도 UI 처리 가능
}핵심 파일: popspot-backend/.../service/WishlistService.java, popspot-backend/.../controller/MyCourseController.java, popspot-frontend/src/components/AuthGuard.tsx