전면 보안 감사 + 수정 — IDOR 5 곳 · 저장형 XSS · 메모리/스토리지 누수 · 클린코드 점검 — POP-SPOT v2.22
인증·인가·인젝션·암호화·프론트·컴플라이언스 6 영역 병렬 감사. C1 IDOR (Stamp/MyPage/Mate/ChatFile) + C2 저장형 XSS (카카오 로드뷰 오버레이) + H1 인증 없는 업로드 + H2/H3 무한 증가 인메모리 맵 + H4 SSE emitter 누수 + H5 토큰 노출. 동시에 운영 점검 4 건 (신고 어뻐징 · 크롤링 입력 정제 · 이메일 열거 · 백업 하드닝).
출시 후 두 주 운영 끝에 전면 보안 감사. 6 영역 (인증 / 인가 / 인젝션 / 암호화 / 프론트 / 컴플라이언스) 을 병렬로 감사하고 모든 고위험 발견은 실제 코드로 검증 후 수정. 동시에 클린코드 관점에서도 점검.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| IDOR | Insecure Direct Object Reference. "주소창의 id 만 바꾸면 남의 게 보이는" 종류의 결함 |
| 저장형 XSS | 공격 스크립트가 DB 에 저장되어 다른 사용자에게 전달되면서도 실행되는 공격 |
| OOM | Out Of Memory. JVM 메모리 부족으로 프로세스 죽음 |
| Caffeine | v2.19 에서 도입한 로컬 메모리 캐시. 이번엔 교체 — 자동 만료 + 최대 크기로 무한 증가 차단 |
| same-origin | "같은 출처" — 도메인/포트/프로토콜이 완전히 같은 경우만 API 호출 허용 |
C1 — IDOR 5 곳
구조적 원인
SecurityConfig 가 /api/** 를 permitAll 으로 두고 개별 컨트롤러에서 인증을 확인하는 구조. 그런데 몇 컨트롤러가 인증 구가 없고 "클라이언트가 보낸 userId 를 신뢰" 하는 상태. 다섯 곳:
수정 패턴
파일: popspot-backend/src/main/java/com/popspot/security/SecurityUtils.java (v2.22 신규 공용 헬퍼)
// 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("다른 사용자 자원 접근 불가");
}
}
}해석
Stamp 수정 예
파일: popspot-backend/src/main/java/com/popspot/controller/StampController.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);
}// 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);
}해석
Mate 수정
파일: popspot-backend/src/main/java/com/popspot/service/MateService.java + 컸트롤러 MateController.java
// v2.21 까지 — MateService.createPost. DTO 안 userId 신뢰
public MatePost createPost(MatePostRequest dto) {
return matePostRepository.save(
MatePost.of(dto.getUserId(), dto.getTitle(), dto.getContent()));
// ^^^^^^^^^^^^^^^^ 클라이언트 입력 그대로
}// v2.22 — userId 별도 인자로 분리. 컨트롤러가 토큰에서 추출하여 주입
public MatePost createPost(MatePostRequest dto, Long userId) {
return matePostRepository.save(
MatePost.of(userId, dto.getTitle(), dto.getContent()));
// ^^^^^^^ 토큰 기반 — 클라이언트에서 온 입력 안 쓰임
// MatePostRequest DTO 의 userId 필드는 아예 제거 — 아키텍처 수준 안전
}해석
C2 — 저장형 XSS
상세
카카오 로드뷰 오버레이에 팝업 이름을 표시하는 코드.
// v2.21 까지 — KakaoRoadview.tsx. popup.name 을 날것으로 주입
overlay.setContent(`<div class="popup-name">${popup.name}</div>`);
// ^^^^^^^^^^^^^^^^ HTML 이스케이프 없이 삽입 → 저장형 XSS 취약점popup.name 이 자동수집된 값이면? 공격자가 네이버·카카오 검색 소스에 장식된 이름을 넣고 LLM 이 정규화해서 저장하면:
popup.name = "성수 웰머 테마파크<img src=x onerror=alert(document.cookie)>"이 팝업을 로드뷰로 열어본 모든 사용자 브라우저에서 자바스크립트 실행 — 쿠키 탈취 가능.
수정
파일: popspot-frontend/src/lib/escapeHtml.ts (v2.22 신규)
// v2.22 — src/lib/escapeHtml.ts (신규)
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}파일: 같은 KakaoRoadview.tsx (v2.22 수정 후)
// v2.22 — KakaoRoadview.tsx 이스케이프 적용
overlay.setContent(`<div class="popup-name">${escapeHtml(popup.name)}</div>`);
// ^^^^^^^^^^^^^^^^^^^^^^^^^ <script> → <script> 로 변환·실행 차단해석
H2/H3 — 무한 증가 인메모리 맵 → Caffeine 교체
문제
파일: popspot-backend/src/main/java/com/popspot/service/AuthService.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 교체 후)
// 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);
// 나머지는 동일
}해석
동일 수정: RateLimitInterceptor.buckets 도 ConcurrentHashMap → Caffeine.
H4 — SSE emitter 누수
문제
파일: popspot-backend/src/main/java/com/popspot/admin/log/LogTailService.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 수정 후)
// 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;
}해석
H5 — 토큰 노출 부분 해결
same-origin 가드
파일: popspot-frontend/src/lib/api.ts (v1.5 이후 지속 수정)
// 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',
});
}해석
OAuth 콜백 토큰 스크럽
파일: popspot-frontend/src/app/oauth/callback/page.tsx 또는 OAuth 완료 후 메인 진입 지점
// 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);
}, []);해석
2 차 운영 점검
신고 자동숨김 어뻐징
-- 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);해석
크롤링 입력 HTML 정제
파일: popspot-backend/src/main/java/com/popspot/service/crawler/PopupNormalizationService.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;
}해석
클린코드 백로그 TOP 5
감사 동시에 SRP/OCP 관점에서 리팩터 TOP 5 도출.
보류한 것
의도적으로 이번엔 안 건드린 것:
해지 코드
감사 과정에서 입증: