[CORS 취약점 해결기 1] Spring Security CORS 설정 오류로 인한 JWT 인증 우회 취약점

Xylitol311·2026년 1월 20일

Back-end

목록 보기
10/14
post-thumbnail

시리즈 구성

  • 1편: CORS 보안 취약점 발견과 개념 이해
  • 2편: CORS 설정 옵션 분석과 해결 방법
  • 3편: 실제 적용과 검증

목차

  1. 보안 취약점 발견
  2. CORS란 무엇인가?
  3. CORS 동작 원리 심층 분석
  4. 문제 원인 분석

보안 취약점 발견

증상

프론트엔드를 로컬 환경(localhost:5173)에서 실행하고, 배포된 개발 서버의 API를 호출했을 때 심각한 문제를 발견했다.

// 프론트엔드 (localhost:5173)
fetch('https://api.example.com/api/v1/users/me', {
  // JWT 토큰 없음!
  headers: {
    'Origin': 'http://localhost:5173'
  }
})
.then(res => res.json())
.then(data => console.log(data)) // 200 OK - 데이터 조회 성공!

심각한 문제:

  • 로그아웃 상태에서도 보호된 API 접근 가능
  • JWT 토큰 없이 사용자 정보 조회 가능
  • 인증/인가 절차 완전히 우회

재현 방법

# JWT 토큰 없이 보호된 API 호출
curl -X GET https://api.example.com/api/v1/users/me \
  -H "Origin: http://localhost:5173"

# 예상: 401 Unauthorized
# 실제: 200 OK (데이터 반환) ← 취약점 발생

CORS란 무엇인가?

기본 개념

CORS (Cross-Origin Resource Sharing)는 웹 브라우저가 다른 출처(도메인)의 리소스에 접근할 수 있도록 허용하는 보안 메커니즘이다.

출처(Origin) = 프로토콜 + 도메인 + 포트

예시:
- http://localhost:5173 (프론트엔드)
- https://api.example.com (백엔드)
→ 다른 출처!

왜 필요한가?

Same-Origin Policy (동일 출처 정책)

  • 브라우저는 보안상 다른 출처의 리소스 접근을 기본적으로 차단
  • XSS, CSRF 등의 공격 방지

CORS의 역할

  • 서버가 명시적으로 허용한 출처에만 접근 허용
  • 브라우저와 서버 간의 협상 프로토콜

CORS 동작 방식

1. Simple Request (단순 요청)

# 브라우저 → 서버
GET /api/users HTTP/1.1
Origin: http://localhost:5173

# 서버 → 브라우저
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

2. Preflight Request (사전 요청)

# 1단계: OPTIONS 요청 (Preflight)
OPTIONS /api/users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

# 서버 응답
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 3600

# 2단계: 실제 요청
POST /api/users HTTP/1.1
Origin: http://localhost:5173
Content-Type: application/json

CORS 주요 헤더

헤더방향설명
Origin요청요청을 보낸 출처 (브라우저가 자동 추가)
Access-Control-Allow-Origin응답허용할 출처 (* 또는 특정 도메인)
Access-Control-Allow-Credentials응답쿠키/인증 정보 허용 여부
Access-Control-Allow-Methods응답허용할 HTTP 메서드
Access-Control-Allow-Headers응답허용할 요청 헤더
Access-Control-Max-Age응답Preflight 결과 캐시 시간 (초)

CORS 동작 원리 심층 분석

CORS는 브라우저와 서버의 협상 프로토콜

CORS는 브라우저 보안 정책이다. 서버가 브라우저에게 "이 출처는 허용해줘"라고 알려주는 메커니즘이며, CORS 헤더는 서버가 응답에 추가한다.

상세 동작 과정

Step 1: 브라우저가 Origin 헤더 자동 추가

// 프론트엔드 코드 (http://localhost:5173)
fetch('https://api.example.com/api/users/me')

브라우저 내부 동작:

# 브라우저가 자동으로 Origin 헤더 추가!
GET /api/users/me HTTP/1.1
Host: api.example.com
Origin: http://localhost:5173  ← 개발자가 직접 추가하지 않음!

중요:

  • Origin 헤더는 브라우저가 자동으로 추가
  • 프론트엔드 개발자는 직접 추가할 수 없음 (보안상 이유)
  • 브라우저가 "다른 출처"임을 감지하면 자동 추가

Step 2: 서버가 CORS 헤더 추가

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173"));
    configuration.setAllowCredentials(true);
    // ...
}

서버 응답:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173  ← 서버가 추가!
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"id": 1, "name": "John"}

Step 3: 브라우저가 CORS 검증

브라우저 내부 로직:

1. 요청 시 보냈던 Origin 확인
   → "http://localhost:5173"

2. 응답의 Access-Control-Allow-Origin 확인
   → "http://localhost:5173"

3. 비교 검증
   → 일치

4. 결정
   → 프론트엔드 JavaScript에 응답 데이터 전달

만약 불일치하거나 헤더 없음:
   → !!CORS Error 발생!!
   → JavaScript에 응답 데이터 전달하지 않음
   → 콘솔에 에러 출력

에러 처리 과정에서 생긴 의문

Q1: CORS 헤더는 누가 추가하는 건지?

A: 서버가 응답에 추가

잘못된 이해:
프론트엔드 → "Access-Control-Allow-Origin 헤더 추가해서 요청"

올바른 이해:
프론트엔드 → 요청
서버 → "Access-Control-Allow-Origin 헤더 추가해서 응답"
브라우저 → 헤더 확인 후 허용/차단 결정

Q2: 그렇다면 왜 서버가 CORS 헤더를 추가해야 하는지?

A: 브라우저의 Same-Origin Policy 때문에

브라우저의 기본 정책:
- 다른 출처의 응답은 JavaScript에 전달하지 않는다.

서버의 역할:
- 특정 출처(http://localhost:5173)는 허용해달라고 요청

브라우저:
- 서버가 허용하는 출처는 OK

만약 서버가 CORS 헤더를 안 보내면:

# 서버 응답 (CORS 헤더 없음)
HTTP/1.1 200 OK
Content-Type: application/json

{"id": 1, "name": "John"}
브라우저 로직:
1. Access-Control-Allow-Origin 헤더 확인
2. 헤더 없음
3. Same-Origin Policy 적용
4. JavaScript에 응답 차단
5. CORS Error 발생

프론트엔드 콘솔:
!!에러 발생!! Access to fetch at 'https://api.example.com' from origin
   'http://localhost:5173' has been blocked by CORS policy

Q3: Preflight는 왜 필요한가?

A: 복잡한 요청의 안전성을 위해 사전 확인을 함

Simple Request (Preflight 불필요):

조건:
- GET, HEAD, POST만 허용
- Content-Type: application/x-www-form-urlencoded,
  multipart/form-data, text/plain만 허용
- 커스텀 헤더 없음

→ 단순한 요청이므로 바로 전송

Complex Request (Preflight 필요):

조건:
- PUT, DELETE, PATCH 사용
- Content-Type: application/json 사용
- Authorization 같은 커스텀 헤더 사용

→ 서버에 특정 요청을 보내도 되는지 먼저 물어봄 (OPTIONS)

Preflight 상세 과정:

# 1단계: 브라우저가 OPTIONS 요청 (자동)
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

# 2단계: 서버 응답
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600

# 3단계: 브라우저 확인
브라우저: "POST 허용 확인. Content-Type 헤더도 허용됨."
브라우저: "3600초(1시간) 동안 캐시할게"

# 4단계: 실제 POST 요청 전송
POST /api/users HTTP/1.1
Host: api.example.com
Origin: http://localhost:5173
Content-Type: application/json

{"name": "John"}

이전 OAuth2 핸들러에서 수동 CORS 헤더를 추가한 이유

문제 상황:

// SecurityConfig.java
http.cors(corsConfig -> corsConfig.disable())  // CORS 비활성화!

결과:

OAuth2 로그인 시도
  ↓
브라우저: "다른 출처 요청인 경우, CORS 체크 진행"
  ↓
Spring Security CorsFilter: 비활성화됨
  ↓
응답에 CORS 헤더 없음
  ↓
브라우저: "CORS 헤더 없으니 차단"
  ↓
OAuth2 로그인 실패

임시방편:

@Component
public class OAuth2AuthenticationSuccessHandler {
    public void onAuthenticationSuccess(...) {
        // 수동으로 CORS 헤더 추가
        String origin = request.getHeader("Origin");
        response.setHeader("Access-Control-Allow-Origin", origin);
        response.setHeader("Access-Control-Allow-Credentials", "true");
    }
}

문제점:

  • OAuth2 로그인: CORS 헤더 있음
  • 일반 API (/api/v1/users/me): CORS 헤더 없음
  • 일관성 없고 유지보수 어려운 문제 발생

문제 원인 분석

잘못된 Spring Security 설정

SecurityConfig.java

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(corsConfig -> corsConfig.disable())  // CORS 비활성화
            .authorizeHttpRequests(authorizeRequests ->
                authorizeRequests
                    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    .requestMatchers(CorsUtils::isCorsRequest).permitAll()  // 취약점 발생!
                    .anyRequest().hasAnyRole("USER", "ADMIN")
            );
    }
}

CorsUtils::isCorsRequest의 위험성

Spring Framework 소스 코드:

// org.springframework.web.cors.CorsUtils
public abstract class CorsUtils {
    public static boolean isCorsRequest(HttpServletRequest request) {
        return (request.getHeader(HttpHeaders.ORIGIN) != null);
    }
}

문제점:

  • Origin 헤더만 있으면 true 반환
  • Preflight (OPTIONS)뿐만 아니라 모든 GET/POST/PUT/DELETE 요청도 포함
  • .permitAll()과 결합하면 인증/인가를 완전히 건너뜀

동작 흐름 분석

1. 브라우저가 요청 전송
   GET /api/v1/users/me
   Origin: http://localhost:5173
   (JWT 토큰 없음)

2. Spring Security 필터 체인
   → CorsUtils::isCorsRequest 체크
   → Origin 헤더 있음? YES
   → .permitAll() 적용
   → !!문제 지점!! 인증 필터(JwtFilter) 건너뜀
   → !!문제 지점!! 인가 체크(.hasAnyRole) 건너뜀

3. 컨트롤러로 바로 전달
   → 200 OK (데이터 반환)

추가 문제: CORS 설정 중복 및 충돌

문제 1: Spring Security CORS 비활성화

.cors(corsConfig -> corsConfig.disable())
  • Spring Security의 CorsFilter 동작하지 않음
  • 응답에 CORS 헤더 없음

문제 2: WebMvcConfigurer로 중복 설정

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")  // 모든 출처 허용
                .allowCredentials(true);
    }
}

WebMvcConfigurer의 한계:

  • Spring Security 이전에 처리
  • 인증이 필요한 엔드포인트에는 적용되지 않음
  • Security 필터 체인에서 차단될 수 있음

문제 3: OAuth2 핸들러에서 수동 추가

@Component
public class OAuth2AuthenticationSuccessHandler {
    public void onAuthenticationSuccess(...) {
        String origin = request.getHeader("Origin");
        response.setHeader("Access-Control-Allow-Origin", origin);
    }
}

총 3곳에 CORS 처리 분산:
1. SecurityConfig (비활성화)
2. WebMvcConfigurer (일부만 동작)
3. OAuth2Handler (수동 추가)

결과:

  • 일관성 없는 CORS 헤더
  • 유지보수 어려움
  • 보안 취약점 발생

정리

  1. 보안 취약점 발견: JWT 없이 보호된 API 접근 가능
  2. CORS 개념 이해: Same-Origin Policy와 브라우저-서버 협상
  3. 동작 원리 분석: Origin 헤더, CORS 헤더, Preflight의 역할
  4. 문제 원인 파악: CorsUtils::isCorsRequest의 잘못된 사용

분량상 문제 해결 과정은 다음 글에서 다룰 예정.

profile
문제에 도전하고 성장하는 백엔드 개발자입니다.

0개의 댓글