주소창 권한 위조 / 티켓 도용(IDOR) / X-Forwarded-Host 헤더 위조 한 번에 잡기 — POP-SPOT v2.7 보안 Critical 3 건 (+v2.7.1/2)
S1) 주소창 isPremium=true&role=ADMIN 을 그대로 저장 / S2) 티켓 API 가 클라이언트 userId 를 신뢰 (IDOR) / S4) X-Forwarded-Host 헤더로 URL 조작. v2.7.1/2 은 이후 빌드 핫픽스 (line-ending, AOSP).
보안 Critical 세 건 + 게스트 모드 시작 시점 재설계를 한 번에 정리. 그 중 둘은 "주소창의 값을 서버가 그대로 신뢰하면 안 됨" 이라는 같은 범주의 이야기다.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| IDOR | Insecure Direct Object Reference. "URL 에 적힌 id 만 바꾸면 남의 게 보이는" 종류의 주소창 감사 부재 주제 |
| 토큰에서 userId 추출 | JWT 토큰 안에 이미 서명되어 있는 userId 를 꺼내 쓰면 클라이언트가 조작 불가 |
| X-Forwarded-Host | 프록시/터널 앞단이 원래 요청한 도메인을 알려주는 표준 헤더. 아무나 붙일 수 있다 |
| AOSP 포맷 | google-java-format 의 4칸 버전. 한국어 멀티라인 주석 자동 재포맷을 주의해야 함 |
| CRLF / LF | 줄바꿈 문자. Windows = CRLF, Linux/Mac = LF. 저장소 규칙 안 맞추면 Spotless 재포맷 속출적 |
세 보안 이슈 요약
| 코드 | 위험 | 동작 방식 | 수정 |
|---|---|---|---|
| S1 | OAuth 콜백 주소창에 isPremium=true·role=ADMIN 을 붙이면 구체적 권한으로 저장 | 프론트가 주소창의 값을 그대로 localStorage 에 저장 | 주소창에서 읽는 코드 제거, OAuth 콜백은 토큰만 처리 |
| S2 | GameController 의 티켓 API 가 클라이언트가 보낸 userId 를 신뢰 | 남의 userId 를 넘기면 그 계정으로 티켓 생성 가능 | 토큰에서 추출한 userId 만 사용 |
| S4 | X-Forwarded-Host 헤더 를 URL 생성에 그대로 사용 | 공격자가 헤더 조작으로 이메일 이벤트/광고를 자기 도메인으로 유도 | 허용 도메인 패턴과 매칭될 때만 신뢰, 아니면 디폴트 도메인 |
| B3 | Controller 가 try-catch 로 자체 응답 생성 | 공통 에러 포맷 깨짐, Sentry 에도 안 잡혀 | 표준 예외 던짐 → GlobalExceptionHandler |
게스트 모드 재설계:
| 항목 | v2.6 | v2.7 |
|---|---|---|
| 게스트 시작점 | 인트로/메인 진입 시 자동 시작 | "게스트로 7일 둘러보기" 버튼을 직접 눌렀을 때만 |
| D-N 노출 | 인트로에만 | 인트로 + 메인 상단 항상 |
| 비로그인 메인 진입 | 자동 게스트 시작 (차단 없음) | 7일 지난 사용자는 회원가입 페이지로 강제 이동 |
왜 이렇게 했음
주소창의 값을 절대 신뢰하면 안 된다 — OAuth 콜백 URL 은 외부 공개. 주소창에 적혀 있는 파라미터는 클라이언트가 뭐든 바꿔서 올 수 있음. 의도는 서버에서 서명한 토큰으로 그림을 그린다는 게 원칙. JWT 토큰에 isPremium/role 이 포함되어 있으며 서버에서만 발급됨.
IDOR 은 "서버가 클라이언트의 ID 입력을 그대로 쓴다" 일 때 생긴다 — 티켓 생성을 POST /api/game/ticket {userId: 7} 처럼 받고 그 userId 를 그대로 쓰면, 7번이 아닌 사용자에게도 티켓을 임의로 만들 수 있다. 해결: 요청 본문의 userId 를 완전 무시하고 SecurityContextHolder.getContext().getAuthentication().getName() 으로 토큰에서 추출.
X-Forwarded-Host — Tailscale Funnel 결과의 원래 도메인을 알아내려는 코드가 있었는데 그 헤더는 공격자가 임의로 조작할 수 있는 값이다. 결국 "허용 도메인 리스트 안에 매칭될 때만" 신뢰하고, 아니면 설정 파일의 디폴트 도메인을 쓴다.
코드로는 어떻게 (필요한 부분만)
IDOR 해결 — 요청 본문의 userId 무시.
// v2.6 까지 — IDOR 취약점. 요청 본문 userId 를 그대로 쓴
@PostMapping("/ticket")
public TicketDto createTicket(@RequestBody TicketRequest req) {
// 위: TicketRequest 안 userId 는 클라이언트 입력이 그대로 매핑됨
return ticketService.create(req.getUserId(), req.getType());
// ^^^^^^^^^^^^^^^ 공격자가 손으로 {userId: 7} 이게 고치고
// 서버에 보내면 7번 사용자의 이름으로 티켓 개설
// 권한 검증 없이 넘어갈 수 있음 — 이게 바로 IDOR
}파일: 같은 GameController.java (v2.7 수정 후)
// v2.7 — 요청 본문의 userId 를 완전 무시. 토큰에서 추출한 값을 쓴
@PostMapping("/ticket")
public TicketDto createTicket(
@AuthenticationPrincipal Long userId,
// ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^ ^^^^^^
// | | |
// | | +-- 이 명캭으로 Spring 이 자동 주입
// | +-- JwtAuthenticationFilter 에서 토큰 검증 후 setPrincipal(userId) 를 통해 온 값
// +-- Spring Security 자동 바인딩. SecurityContext 에서 도출됨
@RequestBody TicketRequest req) {
return ticketService.create(userId, req.getType());
// ^^^^^^ 토큰에서 나온 값 — 클라이언트가 어떤 userId 를 보내도 이 변수는
// 그 요청의 토큰 주인만 주면
// req.getUserId() 는 아예 호출 안 함. 이는 필드이면 TicketRequest 에서 제거하는 게 더 안전
}X-Forwarded-Host 검증.
파일: popspot-backend/src/main/java/com/popspot/util/HostResolver.java (또는 OAuth 콜백 컸트롤러)
// v2.6 까지 — 헤더 검증 없이 그대로 URL 조립
String host = request.getHeader("X-Forwarded-Host");
// ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ 프록시·터널 앞단에서 채워주는 헤더
// 단 공격자가 curl -H 'X-Forwarded-Host: evil.com' 으로 임의 넘김 가능
String redirectUrl = "https://" + host + "/callback";
// ^^^^ 이메일·이벤트 통지 링크 등에 이 URL 이 들어감
// 결과: 메일을 본 사용자는 evil.com/callback 으로 자기도 모르게 이동
// 피싱·토큰 탈취 경로 확보됨파일: 같은 HostResolver.java (v2.7 수정 후)
// v2.7 — 허용 도메인 패턴에 맞는 요청만 신뢰
private static final Pattern ALLOWED =
Pattern.compile("^([a-z0-9-]+\\.)?popspot\\.co\\.kr$");
// ^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ $
// | | | |
// | | | +-- 문자열 끝 (다른 텍스트 이어서 올 수 없음)
// | | +-- popspot.co.kr 정국 도메인 (\\\\. = 실제 점. . 메타문자 회피)
// | +-- ([a-z0-9-]+\\\\.)? — 서브도메인. ? 는 0~1회 (서브도메인 없어도 됨)
// | 예: dev.popspot.co.kr / api.popspot.co.kr / popspot.co.kr 모두 매칭
// +-- 문자열 시작 앤커 — 앞에 다른 텍스트 올 수 없음
String host = request.getHeader("X-Forwarded-Host");
String safeHost = (host != null && ALLOWED.matcher(host).matches())
// ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// | |
// | +-- 패턴 일치 검사. 통과하면 true
// +-- null 검사 먼저. AND 의 단락 평가 (앞 false 면 뒤 호출 안 함)
? host
: DEFAULT_HOST;
// ^^^^^^^^^^^^ 설정의 기본 도메인 (예: popspot.co.kr).
// 헤더 조작 가능성이 있으면 그냥 기본값을 쓴다v2.7.1 line-ending 설정 — 저장소와 머신에서 같이 LF 로.
# v2.7.1 — popspot-backend/.gitattributes. 저장소 수준의 line-ending 고정
*.java text eol=lf
# ^^^^ ^^^^^^
# | |
# | +-- 구체적 끝림문자 = LF (\\n) 강제. Windows 개발자가 저장하는 순간 git 이 LF 로 들어감
# +-- 이 파일들을 이진 아닌 텍스트로 취급
*.gradle text eol=lf
# 위: Spotless 가 LF 기대 — CRLF 파일 있으면 spotlessCheck 가 변경 요청으로 감지해 빌드 실패
# 이 파일을 커밋한 뒤 git add --renormalize . 로 기존 파일도 대량 변환 대상파일: popspot-backend/build.gradle (Spotless 설정 블록)
// v2.7.1 — popspot-backend/build.gradle 에 Spotless 가 LF 기대하도록 명시
spotless {
java {
lineEndings 'UNIX'
// ^^^^^^ 안 적으면 OS 기본을 따름. Windows 개발자 머신에서는 CRLF 생성
// UNIX 명시 시 항상 LF 로 포맓 — .gitattributes 와 이중 안전장
...
// googleJavaFormat('1.22.0').aosp() / removeUnusedImports() / endWithNewline() 등
}
}핵심 파일: popspot-backend/.../controller/GameController.java, popspot-backend/.../config/SecurityConfig.java, popspot-backend/.gitattributes, popspot-backend/build.gradle
비하인드 · 사고
v2.7 머지 후 로컬에서 ./gradlew build 가 spotlessJavaCheck 에서 두 번 실패. 첫 번은 Windows 환경의 CRLF 파일 120 개 (v2.7.1 으로 해결), 두 번째는 5 파일의 한국어 멀티라인 주석을 google-java-format 이 재포맷하다 100 컬럼 제한에 걸림 (v2.7.2 으로 해결). 해결책은 주석 콤팩트화 + if 조건 줄바꿈 + 예외 메시지는 한 줄로 조임.
교훈 한 줄. 자동 포맷터는 멀티라인 한글 주석이 많으면 손이 많이 간다. 한 줄로 쓰고 재포맷 여지를 줄이면 안정적.