백엔드프론트엔드트러블슈팅

주소창 권한 위조 / 티켓 도용(IDOR) / X-Forwarded-Host 헤더 위조 한 번에 잡기 — POP-SPOT v2.7 보안 Critical 3 건 (+v2.7.1/2)

김동현
··5분 읽기

S1) 주소창 isPremium=true&role=ADMIN 을 그대로 저장 / S2) 티켓 API 가 클라이언트 userId 를 신뢰 (IDOR) / S4) X-Forwarded-Host 헤더로 URL 조작. v2.7.1/2 은 이후 빌드 핫픽스 (line-ending, AOSP).

보안 Critical 세 건 + 게스트 모드 시작 시점 재설계를 한 번에 정리. 그 중 둘은 "주소창의 값을 서버가 그대로 신뢰하면 안 됨" 이라는 같은 범주의 이야기다.

이 글에서 다루는 것

  • IDOR 이라는 자주 터지는 공격 계열 — "남의 ID 로 남의 데이터를 조작·조회" — 의 구체적 예 세 가지
  • OAuth 콜백 주소창에 isPremium·role 같은 권한을 넘기고 클라이언트가 그대로 저장하던 사고
  • 티켓 API 가 서버에 적힌 자기 토큰 대신 요청 본문의 userId 를 넣어서 생긴 티켓 도용
  • X-Forwarded-Host 헤더 를 검증 없이 그대로 신뢰해 URL 을 만들다가 다른 도메인으로 유인되는 경로
  • Spring Controller 의 try-catch 를 제거하고 GlobalExceptionHandler 의 공통 응답을 따르게 한 이유
  • v2.7.1 · v2.7.2 의 빌드 핫픽스 — CRLF·한국어 멀티라인 주석 AOSP 포맷터 충돌
  • 모르는 단어 한 줄로

    용어한 줄 설명
    IDORInsecure Direct Object Reference. "URL 에 적힌 id 만 바꾸면 남의 게 보이는" 종류의 주소창 감사 부재 주제
    토큰에서 userId 추출JWT 토큰 안에 이미 서명되어 있는 userId 를 꺼내 쓰면 클라이언트가 조작 불가
    X-Forwarded-Host프록시/터널 앞단이 원래 요청한 도메인을 알려주는 표준 헤더. 아무나 붙일 수 있다
    AOSP 포맷google-java-format 의 4칸 버전. 한국어 멀티라인 주석 자동 재포맷을 주의해야 함
    CRLF / LF줄바꿈 문자. Windows = CRLF, Linux/Mac = LF. 저장소 규칙 안 맞추면 Spotless 재포맷 속출적

    세 보안 이슈 요약

    코드위험동작 방식수정
    S1OAuth 콜백 주소창에 isPremium=true·role=ADMIN 을 붙이면 구체적 권한으로 저장프론트가 주소창의 값을 그대로 localStorage 에 저장주소창에서 읽는 코드 제거, OAuth 콜백은 토큰만 처리
    S2GameController 의 티켓 API 가 클라이언트가 보낸 userId 를 신뢰남의 userId 를 넘기면 그 계정으로 티켓 생성 가능토큰에서 추출한 userId 만 사용
    S4X-Forwarded-Host 헤더 를 URL 생성에 그대로 사용공격자가 헤더 조작으로 이메일 이벤트/광고를 자기 도메인으로 유도허용 도메인 패턴과 매칭될 때만 신뢰, 아니면 디폴트 도메인
    B3Controller 가 try-catch 로 자체 응답 생성공통 에러 포맷 깨짐, Sentry 에도 안 잡혀표준 예외 던짐 → GlobalExceptionHandler

    게스트 모드 재설계:

    항목v2.6v2.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 무시.

    java
    // 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 수정 후)

    java
    // 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 콜백 컸트롤러)

    java
    // 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 수정 후)

    java
    // 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 로.

    plain text
    # 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 설정 블록)

    groovy
    // 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 조건 줄바꿈 + 예외 메시지는 한 줄로 조임.

    교훈 한 줄. 자동 포맷터는 멀티라인 한글 주석이 많으면 손이 많이 간다. 한 줄로 쓰고 재포맷 여지를 줄이면 안정적.


    관련 글

  • 이전 — v2.6, 프론트 결합도 정리
  • 다음 — v2.8, 게스트가 마이페이지도 못 열다 — 탭별 접근 정책
  • 공유

    댓글