CORS는 보안이 아니다? Postman으로 알아보는 CORS의 진실
들어가며
개발하다 보면 누구나 한 번은 만나게 되는 CORS(Cross-Origin Resource Sharing) 에러. 나는 CORS를 보안 메커니즘의 일종으로 이해했었다. 하지만 정말 그럴까?
회사 개발서버에 특정 도메인만 허용하도록 CORS가 설정되어 있는데, 집에서 Postman으로 API를 호출하니까 멀쩡히 동작하는 것을 보고 생각했다. 이상하지 않나?
CORS에 대한 나의 오해
오해 1: “CORS는 보안을 위한 기능이다”
@CrossOrigin(origins = {"https://company-frontend.com"})
@RestController
public class ApiController {
@PostMapping("/sensitive-data")
public ResponseEntity<?> getData() {
// 민감한 데이터 반환
}
}
위 코드를 보면 마치 company-frontend.com
에서만 접근 가능한 것처럼 보인다. 하지만 실제로는…
Postman 테스트로 확인해보기
같은 API를 Postman에서 호출해보자.
POST https://dev-api.company.com/sensitive-data
어? 멀쩡히 동작한다. CORS 에러가 전혀 나지 않는다.
왜 이런 일이 생길까?
CORS는 브라우저만의 정책이기 때문이다.
브라우저에서 JavaScript:
웹페이지 → 브라우저(CORS 검사) → 서버
Postman/curl/서버 요청:
클라이언트 → 서버 (CORS 검사 없음)
CORS의 진짜 목적
CORS는 보안이 아니라 사용자 보호를 위한 메커니즘이다.
시나리오: 악성 웹사이트 방문
<!-- https://malicious-site.com -->
<script>
// 사용자 모르게 은행 API 호출 시도
fetch('https://mybank.com/transfer', {
method: 'POST',
credentials: 'include', // 쿠키 포함
body: JSON.stringify({
to: '해커계좌',
amount: 1000000
})
});
</script>
브라우저의 CORS 정책이 없다면, 악성 사이트가 사용자 몰래 다른 사이트의 API를 호출할 수 있다. 사용자가 은행에 로그인된 상태라면 쿠키도 함께 전송된다.
CORS는 이런 “무심코 당하는 공격”을 막아준다.
하지만 의도적인 공격자는?
악의적인 사용자가 의도적으로 공격하려 한다면?
# Python으로
import requests
requests.post('https://api.company.com/sensitive-data',
json={'malicious': 'payload'})
# curl로
curl -X POST https://api.company.com/sensitive-data \
-H "Content-Type: application/json" \
-d '{"malicious": "payload"}'
# Postman, Insomnia, 심지어 브라우저 확장프로그램으로도...
모두 가능하다. CORS는 전혀 막지 못한다.
진짜 보안은 서버에서
그렇다면 실제 보안은 어떻게 구현해야 할까?
1. 인증 (Authentication)
@PostMapping("/api/data")
public ResponseEntity<?> getData(HttpServletRequest request) {
// JWT 토큰 검증
String token = request.getHeader("Authorization");
if (!jwtService.validateToken(token)) {
return ResponseEntity.status(401).body("Unauthorized");
}
// 실제 비즈니스 로직
}
2. 인가 (Authorization)
@PostMapping("/admin/users")
public ResponseEntity<?> manageUsers(HttpServletRequest request) {
String userId = jwtService.getUserId(request);
// 권한 체크
if (!userService.hasAdminRole(userId)) {
return ResponseEntity.status(403).body("Forbidden");
}
// 관리자만 접근 가능한 로직
}
3. 입력값 검증
@PostMapping("/transfer")
public ResponseEntity<?> transfer(@Valid @RequestBody TransferRequest request) {
// @Valid 어노테이션으로 기본 검증
// 추가 비즈니스 로직 검증
if (request.getAmount() > userService.getMaxTransferLimit(userId)) {
return ResponseEntity.badRequest().body("Transfer limit exceeded");
}
// 실제 이체 로직
}
4. Rate Limiting
@RestController
@RateLimiter(name = "api", fallbackMethod = "rateLimitFallback")
public class ApiController {
@PostMapping("/api/data")
public ResponseEntity<?> getData() {
// API 호출 제한 적용
}
public ResponseEntity<?> rateLimitFallback(Exception ex) {
return ResponseEntity.status(429).body("Too many requests");
}
}
Spring Security를 활용한 종합적 보안
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // API 서버의 경우
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://trusted-frontend.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
여러가지 CORS 시나리오
시나리오 1: 개발 환경에서 프론트엔드 테스트
// 개발용 설정
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:8080"})
@RestController
public class DevApiController {
// 개발 서버에서는 로컬 프론트엔드 허용
}
브라우저에서 localhost:3000
에서 개발 서버로 요청하면 잘 동작한다. 하지만 다른 도메인에서 접근하면 CORS 에러가 발생한다.
시나리오 2: 프로덕션 환경의 엄격한 설정
// 운영용 설정
@CrossOrigin(origins = {"https://production-frontend.com"})
@RestController
public class ProdApiController {
// 운영에서는 특정 도메인만 허용
}
하지만 여전히 Postman, curl, 서버 간 통신은 모두 가능하다.
개발자가 알아야 할 진실
CORS 에러를 만났을 때
// 브라우저 콘솔에서 이런 에러를 본다면
fetch('https://api.example.com/data')
.catch(err => console.log(err));
// CORS policy: No 'Access-Control-Allow-Origin' header...
이건 보안 문제가 아니라 브라우저 정책 문제다. 서버에서 해당 도메인을 허용하지 않아서 발생한다.
하지만 공격자는
# 공격자는 이렇게 우회한다
import requests
response = requests.post('https://api.example.com/data',
headers={'Authorization': 'stolen-token'},
json={'malicious': 'data'})
print(response.text) # 멀쩡히 동작함
CORS 설정과 관계없이 토큰만 있으면 공격 가능하다.
결론
CORS는 보안이 아니라 사용자 편의를 위한 브라우저 정책이다.
- CORS의 역할: 브라우저에서 무심코 당하는 공격 방지
- CORS가 못하는 것: 의도적인 공격자 차단
- 진짜 보안: 서버 사이드의 인증, 인가, 입력값 검증
다음번에 CORS 에러를 만나면, 이것이 보안 문제가 아니라 브라우저의 정책임을 기억하자. 그리고 진짜 보안은 서버에서 튼튼하게 구현해야 한다는 것도.
API 보안을 제대로 하려면 CORS에 의존하지 말고, 인증과 인가를 확실히 구현하는 것이 답이다.
참고자료
잘못된 내용이 있다면 알려주세요.