
시리즈 구성
- 1편: CORS 보안 취약점 발견과 개념 이해
- 2편: CORS 설정 옵션 분석과 해결 방법
- 3편: 실제 적용과 검증
프론트엔드를 로컬 환경(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 - 데이터 조회 성공!
심각한 문제:
# JWT 토큰 없이 보호된 API 호출
curl -X GET https://api.example.com/api/v1/users/me \
-H "Origin: http://localhost:5173"
# 예상: 401 Unauthorized
# 실제: 200 OK (데이터 반환) ← 취약점 발생
CORS (Cross-Origin Resource Sharing)는 웹 브라우저가 다른 출처(도메인)의 리소스에 접근할 수 있도록 허용하는 보안 메커니즘이다.
출처(Origin) = 프로토콜 + 도메인 + 포트
예시:
- http://localhost:5173 (프론트엔드)
- https://api.example.com (백엔드)
→ 다른 출처!
Same-Origin Policy (동일 출처 정책)
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
| 헤더 | 방향 | 설명 |
|---|---|---|
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 헤더는 서버가 응답에 추가한다.
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"}
문제 상황:
// 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");
}
}
문제점:
/api/v1/users/me): CORS 헤더 없음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 반환.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 (데이터 반환)
문제 1: Spring Security CORS 비활성화
.cors(corsConfig -> corsConfig.disable())
문제 2: WebMvcConfigurer로 중복 설정
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") // 모든 출처 허용
.allowCredentials(true);
}
}
WebMvcConfigurer의 한계:
문제 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 (수동 추가)
결과:
CorsUtils::isCorsRequest의 잘못된 사용분량상 문제 해결 과정은 다음 글에서 다룰 예정.