백엔드프론트엔드갈아엎기 시리즈

전면 보안 감사 + 수정 — IDOR 5 곳 · 저장형 XSS · 메모리/스토리지 누수 · 클린코드 점검 — POP-SPOT v2.22

김동현
··8분 읽기

인증·인가·인젝션·암호화·프론트·컴플라이언스 6 영역 병렬 감사. C1 IDOR (Stamp/MyPage/Mate/ChatFile) + C2 저장형 XSS (카카오 로드뷰 오버레이) + H1 인증 없는 업로드 + H2/H3 무한 증가 인메모리 맵 + H4 SSE emitter 누수 + H5 토큰 노출. 동시에 운영 점검 4 건 (신고 어뻐징 · 크롤링 입력 정제 · 이메일 열거 · 백업 하드닝).

출시 후 두 주 운영 끝에 전면 보안 감사. 6 영역 (인증 / 인가 / 인젝션 / 암호화 / 프론트 / 컴플라이언스) 을 병렬로 감사하고 모든 고위험 발견은 실제 코드로 검증 후 수정. 동시에 클린코드 관점에서도 점검.

이 글에서 다루는 것

  • C1 IDOR — Stamp / MyPage / Mate (5) / ChatFile 업로드 다섯 곳에 userId 쿼리/바디 떨어지던 곳
  • C2 저장형 XSS — 카카오 로드뷰 오버레이에 popup.name 을 raw HTML 주입
  • H1 인증 없는 파일 업로드 → 디스크 DoS
  • H2/H3 무한 증가 인메모리 맵 (RateLimit · 로그인 시도 카운트) → Caffeine 교체
  • H4 SSE emitter 누수 30 분 타임아웃
  • H5 OAuth 콜백 URL 토큰 스크럽 + same-origin 가드
  • 2 차 운영 점검 — 신고 동일인 1인 1회, 크롤링 입력 HTML 정제, 이메일 열거 제한, pg_dump 파이프 하드닝
  • 모르는 단어 한 줄로

    용어한 줄 설명
    IDORInsecure Direct Object Reference. "주소창의 id 만 바꾸면 남의 게 보이는" 종류의 결함
    저장형 XSS공격 스크립트가 DB 에 저장되어 다른 사용자에게 전달되면서도 실행되는 공격
    OOMOut Of Memory. JVM 메모리 부족으로 프로세스 죽음
    Caffeinev2.19 에서 도입한 로컬 메모리 캐시. 이번엔 교체 — 자동 만료 + 최대 크기로 무한 증가 차단
    same-origin"같은 출처" — 도메인/포트/프로토콜이 완전히 같은 경우만 API 호출 허용

    C1 — IDOR 5 곳

    구조적 원인

    SecurityConfig/api/** 를 permitAll 으로 두고 개별 컨트롤러에서 인증을 확인하는 구조. 그런데 몇 컨트롤러가 인증 구가 없고 "클라이언트가 보낸 userId 를 신뢰" 하는 상태. 다섯 곳:

  • StampController.collect — GET 으로 주소창에 userId 쿼리
  • MyPageController.summary — 마이페이지 조회에 userId 주소창
  • MateController 5 개 엔드포인트 — 게시글 만들기/댓글 달기 등에 요청 바디 userId
  • ChatFileController.upload — 채팅방 파일 업로드에 userId 주소창
  • 수정 패턴

    파일: popspot-backend/src/main/java/com/popspot/security/SecurityUtils.java (v2.22 신규 공용 헬퍼)

    java
    // v2.22 — SecurityUtils.java (신규 공용 헬퍼)
    public class SecurityUtils {
      public static Long currentUserId(Authentication auth) {
        if (auth == null || !auth.isAuthenticated()) {
          throw new UnauthorizedException("로그인 필요");
        }
        return Long.parseLong(auth.getName());
      }
    
      public static void requireSelf(Authentication auth, Long requestedUserId) {
        Long me = currentUserId(auth);
        if (!me.equals(requestedUserId)) {
          throw new ForbiddenException("다른 사용자 자원 접근 불가");
        }
      }
    }

    해석

  • currentUserId — 항상 토큰에서만 userId 추출. 권한 필드 별도 검사 안 함
  • requireSelf — "내 것" 조회하는 경우에 쓰임. requested == self 아니면 403
  • Stamp 수정 예

    파일: popspot-backend/src/main/java/com/popspot/controller/StampController.java

    java
    // v2.21 까지 — StampController. 주소창 userId 신뢰 (IDOR)
    @PostMapping("/collect")
    public StampResponse collect(
        @RequestParam Long userId,
    //                  ^^^^^^^^^^^^^^^   ?userId=X 을 그대로 수용 → 타인의 ID 로 스탬프 찍는 게 가능했음
        @RequestParam Long popupId) {
      return stampService.collect(userId, popupId);
    }
    java
    // v2.22 — 주소창 userId 제거. 토큰에서 추출
    @PostMapping("/collect")
    public StampResponse collect(
        Authentication auth,
    //  ^^^^^^^^^^^^^^^^^^^   Spring 자동 주입. JWT 필터가 검증 후 주입한 객체
        @RequestParam Long popupId) {
      Long userId = SecurityUtils.currentUserId(auth);
      //            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   공용 헬퍼 — auth.getName() 결과를 Long 으로
      return stampService.collect(userId, popupId);
    }

    해석

  • 요청 매개변수에서 userId 제거 — API 시그니처 자체가 변경. 프론트도 같이 수정
  • Authentication 계 파라미터는 Spring 이 자동 주입. 요청에 명시적으로 채워넣을 필요 없음
  • Mate 수정

    파일: popspot-backend/src/main/java/com/popspot/service/MateService.java + 컸트롤러 MateController.java

    java
    // v2.21 까지 — MateService.createPost. DTO 안 userId 신뢰
    public MatePost createPost(MatePostRequest dto) {
      return matePostRepository.save(
          MatePost.of(dto.getUserId(), dto.getTitle(), dto.getContent()));
    //                ^^^^^^^^^^^^^^^^                                     클라이언트 입력 그대로
    }
    java
    // v2.22 — userId 별도 인자로 분리. 컨트롤러가 토큰에서 추출하여 주입
    public MatePost createPost(MatePostRequest dto, Long userId) {
      return matePostRepository.save(
          MatePost.of(userId, dto.getTitle(), dto.getContent()));
    //                ^^^^^^^                                     토큰 기반 — 클라이언트에서 온 입력 안 쓰임
      // MatePostRequest DTO 의 userId 필드는 아예 제거 — 아키텍처 수준 안전
    }

    해석

  • MateService 시그니처에서 userId 를 분리 — dto 안의 userId 가 "세팅 수준으로 존재하지" 않도록. 컨트롤러 계층에서 토큰 userId 를 별도 인자로 넘김
  • DTO 의 userId 필드 자체는 제거 — 클라이언트가 해당 필드를 보내면 서버가 그저 무시하도록 아키텍처 수준의 안전

  • C2 — 저장형 XSS

    상세

    카카오 로드뷰 오버레이에 팝업 이름을 표시하는 코드.

    typescript
    // v2.21 까지 — KakaoRoadview.tsx. popup.name 을 날것으로 주입
    overlay.setContent(`<div class="popup-name">${popup.name}</div>`);
    //                                            ^^^^^^^^^^^^^^^^   HTML 이스케이프 없이 삽입 → 저장형 XSS 취약점

    popup.name 이 자동수집된 값이면? 공격자가 네이버·카카오 검색 소스에 장식된 이름을 넣고 LLM 이 정규화해서 저장하면:

    javascript
    popup.name = "성수 웰머 테마파크<img src=x onerror=alert(document.cookie)>"

    이 팝업을 로드뷰로 열어본 모든 사용자 브라우저에서 자바스크립트 실행 — 쿠키 탈취 가능.

    수정

    파일: popspot-frontend/src/lib/escapeHtml.ts (v2.22 신규)

    typescript
    // v2.22 — src/lib/escapeHtml.ts (신규)
    export function escapeHtml(unsafe: string): string {
      return unsafe
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }

    파일: 같은 KakaoRoadview.tsx (v2.22 수정 후)

    typescript
    // v2.22 — KakaoRoadview.tsx 이스케이프 적용
    overlay.setContent(`<div class="popup-name">${escapeHtml(popup.name)}</div>`);
    //                                            ^^^^^^^^^^^^^^^^^^^^^^^^^   <script> → &lt;script&gt; 로 변환·실행 차단

    해석

  • 5 가지 HTML 특수문자를 엔터티로 변환. <script>&lt;script&gt; 로 실행 불가
  • & 를 먼저 변환 — 그 외 기호 변환 결과에 &amp; 가 포함되어 이중 이스케이프 되는 것 방지
  • 동일 수정: 팝업 상세 페이지의 dangerouslySetInnerHTML 구간
  • React 는 기본으로 escape 하지만, dangerouslySetInnerHTML 과 커스텀 SDK 의 setContent 같은 경우에는 명시적 escape 필요

  • H2/H3 — 무한 증가 인메모리 맵 → Caffeine 교체

    문제

    파일: popspot-backend/src/main/java/com/popspot/service/AuthService.java

    java
    // v2.17 — v2.21 까지 — AuthService. 이메일별 로그인 실패 카운트 무제한 증가
    private final Map<String, FailureRecord> loginAttempts = new ConcurrentHashMap<>();
    //                                                       ^^^^^^^^^^^^^^^^^^^^^^^^   만료 타임아웃 없으면 OOM 위험

    v2.17 의 brute-force 잠금용 맵. 성공 시 remove 되지만 일부 실패 때는 잠금 만료·리셋 이 모호해서 엔트리가 무한으로 쌓일 수 있음. 특히 공격자가 있지도 않은 이메일로 계속 시도하면 일주일 동안 100만 엔트리 OOM 가능.

    수정

    파일: 같은 AuthService.java (v2.22 교체 후)

    java
    // v2.22 — Caffeine 으로 교체. 크기 한도 + 자동 만료
    private final Cache<String, FailureRecord> loginAttempts =
        Caffeine.newBuilder()
            .maximumSize(10_000)
    //                   ^^^^^^   1만 개 넘으면 LRU 제거 — 적대적 입력도 무한히 남아있을 수 없음
            .expireAfterWrite(Duration.ofMinutes(30))
    //                                            ^^   30분 동안 재시도 없는 email 은 자동 제거
            .build();
    
    public AuthResult login(String email, String rawPassword) {
      FailureRecord rec = loginAttempts.getIfPresent(email);
      // 나머지는 동일
    }

    해석

  • maximumSize(10_000) — 1만 개 넘으면 LRU 제거. 최악의 경우에도 OOM 불가
  • expireAfterWrite(30분) — 30분 동안 재시도 없는 email 은 자동 제거
  • getIfPresent — ConcurrentHashMap 의 get() 과 동일. Caffeine 도 유사 인터페이스라 거의 그대로 대체 가능
  • 동일 수정: RateLimitInterceptor.buckets 도 ConcurrentHashMap → Caffeine.


    H4 — SSE emitter 누수

    문제

    파일: popspot-backend/src/main/java/com/popspot/admin/log/LogTailService.java

    java
    // v2.21 까지 — LogTailService.subscribe. 타임아웃 없음, 에러 콜백 없음
    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
    
    public SseEmitter subscribe() {
      SseEmitter em = new SseEmitter(0L);  // 0L = 타임아웃 없음 → 영원히 유지
      emitters.add(em);
      em.onCompletion(() -> emitters.remove(em));
      em.onTimeout(() -> emitters.remove(em));
      // 이 둘만 명시 — onError 는 빠짐 → 브라우저 강제 종료·네트워크 에러 등 이면 제거 안 됨
      return em;
    }

    타임아웃 없음 + 수동 끝내기 만 의존 → 클라이언트가 브라우저 강제 종료 등으로 그냥 사라지면 emitter 가 서버에 영구히 남아 메모리 누수.

    수정

    파일: 같은 LogTailService.java (v2.22 수정 후)

    java
    // v2.22 — 하드 타임아웃 + 상한 + onError 보완
    private static final int MAX_SUBSCRIBERS = 50;
    private static final Duration TIMEOUT = Duration.ofMinutes(30);
    
    public SseEmitter subscribe() {
      if (emitters.size() >= MAX_SUBSCRIBERS) {
        throw new TooManyRequestsException("동시 구독 제한 초과");
      }
      SseEmitter em = new SseEmitter(TIMEOUT.toMillis());
      emitters.add(em);
      em.onCompletion(() -> emitters.remove(em));
      em.onTimeout(() -> emitters.remove(em));
      em.onError(t -> emitters.remove(em));
      return em;
    }

    해석

  • TIMEOUT 30분 — 자동 끊김. 어떤 이유로든 30분 흐르면 서버가 닫음
  • MAX_SUBSCRIBERS 50 — 어드민 용이니 몇 명 안 될 텍데 50은 보수적 상한
  • onError — 이게 빠져있었음. 에러로 끝나는 emitter 가 제거 안 됨

  • H5 — 토큰 노출 부분 해결

    same-origin 가드

    파일: popspot-frontend/src/lib/api.ts (v1.5 이후 지속 수정)

    typescript
    // v2.22 — src/lib/api.ts. same-origin 가드
    export async function apiFetch(path: string, init?: RequestInit) {
      const url = new URL(path, window.location.origin);
      if (url.origin !== window.location.origin) {
        throw new Error('외부 도메인 호출 거부');
      }
      return fetch(url.toString(), {
        ...init,
        credentials: 'include',
      });
    }

    해석

  • new URL(path, origin) — 상대 경로이면 현재 출처 + path, 절대 포함 URL 이면 그 origin 으로 파싱
  • 외부 도메인 거부 — 공격자가 path 에 "https://evil.com/api" 따위 넣어도 외부로 나가지 않음. credentials: 'include' 가 외부 서버에 JWT 쿠키 보내는 게 차단
  • OAuth 콜백 토큰 스크럽

    파일: popspot-frontend/src/app/oauth/callback/page.tsx 또는 OAuth 완료 후 메인 진입 지점

    typescript
    // v2.22 — OAuth 콜백 직후 주소창의 토큰·코드 제거
    useEffect(() => {
      const params = new URLSearchParams(window.location.search);
      ['access_token', 'token', 'code', 'state'].forEach(k => params.delete(k));
      const clean = window.location.pathname
                  + (params.toString() ? `?${params}` : '');
      window.history.replaceState({}, '', clean);
    }, []);

    해석

  • OAuth 콜백 URL 은 토큰·코드가 주소창에 그대로 뜨는 구조. 사용자가 그대로 공유하면 토큰 노출
  • replaceState 으로 주소창에서 토큰 관련 쿼리 제거. 펜이지 레코드 히스토리에도 남아있도록

  • 2 차 운영 점검

    신고 자동숨김 어뻐징

    sql
    -- v2.22 — V14__mate_post_reported_by.sql
    ALTER TABLE mate_post
      ADD COLUMN reported_by JSONB DEFAULT '[]'::jsonb;
    --           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   v2.18.1 의 reportCount 그대로는 동일인 중복 신고도 카운트되어 신고 어분·주·어·· ··· ·
    
    -- 기존 reportCount 대체
    CREATE INDEX idx_mate_post_reported_by ON mate_post USING GIN (reported_by);

    해석

  • v2.18.1 의 3 회 임계 자동 숨김은 reportCount 만 봤다 → 한 공격자가 세 번 신고하면 숨김
  • reported_by 에 reporter userId 들을 JSONB 배열로 저장. 같은 reporter 가 중복 신고해도 배열 길이 안 늘어남 → 실제 다른 3 명의 신고만 자동숨김 트리거
  • GIN 인덱스 — JSONB 배열 포함 검색 고속화
  • 크롤링 입력 HTML 정제

    파일: popspot-backend/src/main/java/com/popspot/service/crawler/PopupNormalizationService.java

    java
    // v2.22 — PopupNormalizationService.java. 크롤링 입력 HTML 정제
    private String sanitize(String raw) {
      if (raw == null) return null;
      String trimmed = raw.trim();
      // HTML 태그 제거
      String withoutHtml = trimmed.replaceAll("<[^>]+>", "");
      // 길이 상한
      if (withoutHtml.length() > 200) {
        withoutHtml = withoutHtml.substring(0, 200);
      }
      return withoutHtml;
    }

    해석

  • 단순 정규식 HTML 제거 — 한 계층 방어. 프론트의 escapeHtml 과 2 중 방어 (완전 차단은 아니다)
  • 길이 상한 — 년고 복사한 안뱕한 글로 DB 파일 증가 방지
  • 클린코드 백로그 TOP 5

    감사 동시에 SRP/OCP 관점에서 리팩터 TOP 5 도출.

  • SecurityUtils.currentUserId 추출 — 이번 감사에서 만들어 둔 것을 컨트롤러 6 곳에 적용
  • FileUploadService 추출 — 업로드 로직 80 줄이 복붙이되어 있음
  • JwtService 빈 추출 — JWT 발급 2 곳 중복
  • app/page.tsx 분해 — 1,592 줄 / 27 state — 테스트 프레임으로도 잘 붙지 않음
  • RestTemplate · Clock 주입 — 테스트 작성 전제

  • 보류한 것

    의도적으로 이번엔 안 건드린 것:

  • jjwt 0.11 → 0.12 업그레이드 — 인증 API 파괴적, 별도 종합 테스트 세팅 필요
  • httpOnly 쿠키 + CSP nonce — 교차오리진 변경과 함께 가야을 경우
  • RestTemplate 12 곳 타임아웃 — 교체 증상 레퍼런스 없이는 위험
  • 탈퇴 시 작성 콘텐츠 삭제 — 다른 사용자 대화 맥락 깨짐
  • 해지 코드

    감사 과정에서 입증:

  • TicketingSimulation.tsx — v1.0 결제 테스트는 폐기됨
  • GoodsInitializer.java — 초기 상점 샘플 데이터
  • YouTubeService.java — 잘못된 위치 (controller 패키지 안에 있음)
  • 미사용 @Slf4j 4 곳

  • 관련 글

  • 이전 — v2.21 S10~S18, Spotify 3-tier 재생 엔진
  • 다음 — v2.23, 인트로 페이지 제거 + SEO 색인 함정 수정
  • 공유

    댓글