CORS 설정 오류가 발생하는 원인과 Access-Control-Allow-Origin 헤더의 동작 원리, 취약한 Spring 설정 및 안전한 허용 출처 구성 방법을 알아봅니다.
웹서비스는 프론트엔드와 API 서버를 서로 다른 도메인으로 분리해 운영하는 경우가 많다. 예를 들어 화면은 https://app.example.com에서 제공하고 사용자 정보는 https://api.example.com에서 가져올 수 있다.
브라우저는 보안을 위해 출처가 다른 웹페이지의 응답을 자유롭게 읽지 못하도록 제한한다. 이때 신뢰할 수 있는 출처에만 API 응답을 허용하기 위해 사용하는 기능이 CORS다. 허용 범위를 지나치게 넓게 설정하면 공격자가 만든 웹페이지에서도 중요 응답을 읽을 수 있는데, 이를 CORS 설정 오류라고 한다.
동일 출처 정책이란?
브라우저에는 동일 출처 정책인 SOP가 적용된다. 한 웹페이지가 다른 출처의 데이터를 임의로 읽는 것을 제한하는 보안 정책이다.
출처는 프로토콜, 호스트와 포트의 조합으로 구분한다. 다음 두 주소는 호스트가 같아 보여도 프로토콜이나 포트가 다르므로 서로 다른 출처가 될 수 있다.
https://app.example.com
http://app.example.com
https://app.example.com:8443
공격자가 만든 사이트에서 사용자의 인터넷뱅킹이나 사내 시스템 응답을 마음대로 읽을 수 있다면 개인정보와 중요 데이터가 쉽게 유출될 수 있다. 동일 출처 정책은 이러한 상황을 막는 브라우저의 기본 방어선이다.
다만 실제 서비스에서는 서로 다른 출처 사이의 정상적인 통신이 필요하다. 서버는 CORS 응답 헤더를 이용해 어떤 출처의 웹페이지가 응답을 읽을 수 있는지 브라우저에 알려준다.
CORS란?
CORS는 ‘Cross-Origin Resource Sharing’의 약자로, 교차 출처 리소스 공유를 의미한다. 서버가 신뢰하는 외부 출처를 지정해 브라우저의 동일 출처 정책을 선택적으로 완화하는 기능이다.
브라우저는 교차 출처 요청을 보낼 때 다음과 같은 Origin 헤더를 포함할 수 있다.
Origin: https://app.example.com
API 서버가 해당 출처를 허용한다면 응답에 다음 헤더를 추가한다.
Access-Control-Allow-Origin: https://app.example.com
브라우저는 요청의 Origin 값과 서버가 허용한 출처를 비교한다. 두 값이 일치하면 웹페이지의 JavaScript가 응답 내용을 읽을 수 있도록 허용한다.
CORS는 브라우저가 응답을 읽을 수 있는지를 제어하는 정책이다. 서버에 대한 사용자 인증이나 API 권한 검사를 대신하는 기능은 아니다.
CORS 설정 오류란?
CORS 설정 오류는 신뢰하지 않은 출처에 중요 API 응답을 허용하거나 요청의 Origin 값을 검증하지 않고 그대로 응답에 반영하는 문제다.
사용자가 공격자의 웹페이지에 접속했을 때 브라우저에는 다른 서비스의 로그인 쿠키가 남아 있을 수 있다. 취약한 API가 공격자의 출처를 허용하고 자격 증명까지 사용할 수 있게 설정돼 있다면 공격 페이지의 JavaScript가 로그인 사용자의 정보를 읽을 가능성이 생긴다.
CORS 보안의 핵심은 교차 출처 요청을 모두 허용하는 것이 아니라 실제로 신뢰하는 출처만 정확하게 허용하는 것이다.
Access-Control-Allow-Origin 헤더의 의미
Access-Control-Allow-Origin은 응답을 읽도록 허용할 출처를 지정하는 핵심 CORS 헤더다.
모든 출처에 공개해도 되는 데이터라면 다음과 같이 별표를 사용할 수 있다.
Access-Control-Allow-Origin: *
별표 설정이 항상 취약한 것은 아니다. 인증이 필요 없고 누구에게나 공개할 목적으로 만든 API라면 사용할 수 있다. 하지만 개인정보, 계정 정보나 사내 데이터처럼 민감한 응답에는 적용해서는 안 된다.
로그인 쿠키 등의 자격 증명을 포함하는 요청을 허용하려면 서버가 특정 출처를 반환하고 다음 헤더도 설정해야 한다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
자격 증명을 허용하면서 Access-Control-Allow-Origin: *를 사용하는 조합은 브라우저 표준상 허용되지 않는다. 이를 피하려고 요청의 Origin 값을 그대로 반환하는 방식은 더 위험한 설정이 될 수 있다.
CORS 설정 오류가 발생하는 원인
가장 흔한 원인은 개발 과정에서 발생한 CORS 오류를 빠르게 해결하기 위해 모든 출처와 메서드 및 헤더를 허용하는 것이다. 개발 환경에서는 편리하지만 해당 설정이 운영 서버까지 유지되면 보안 문제가 된다.
요청의 Origin 값을 읽어 검증 없이 Access-Control-Allow-Origin으로 반환하는 코드도 위험하다. 서버가 공격자의 출처까지 신뢰하는 결과가 되기 때문이다.
도메인 문자열이 포함돼 있는지만 확인하는 방식도 안전하지 않다. 허용한 도메인과 비슷한 이름의 공격자 도메인이 검사를 통과할 수 있다.
정규표현식을 지나치게 넓게 작성하거나 모든 하위 도메인을 허용하는 설정도 주의해야 한다. 더 이상 사용하지 않는 하위 도메인이 공격자에게 넘어가거나 특정 하위 서비스에 XSS가 존재하면 중요 API에 접근하는 출처로 악용될 수 있다.
null Origin을 무조건 허용하는 것도 피해야 한다. 샌드박스 iframe이나 로컬 파일 등 일부 환경에서는 Origin이 null로 전송될 수 있어 신뢰할 출처를 정확히 구분하기 어렵다.
취약한 CORS 응답 코드
다음 코드는 요청의 Origin 값을 응답에 그대로 반영한다.
@GetMapping("/api/profile")
public UserProfile profile(
HttpServletRequest request,
HttpServletResponse response) {
String origin =
request.getHeader("Origin");
response.setHeader(
"Access-Control-Allow-Origin",
origin
);
response.setHeader(
"Access-Control-Allow-Credentials",
"true"
);
return profileService.getCurrentUser();
}
공격자의 웹페이지에서 요청을 보내면 Origin 헤더에는 공격자 사이트의 주소가 들어간다. 서버는 해당 값을 검증하지 않고 허용 출처로 다시 반환한다.
브라우저는 공격자의 출처가 서버에서 허용된 것으로 판단할 수 있다. 사용자의 인증 쿠키가 요청에 포함되는 조건까지 충족한다면 공격 페이지에서 개인정보 응답을 읽을 가능성이 생긴다.
CORS 헤더를 컨트롤러마다 직접 작성하면 일부 기능에서 검증이 빠지거나 서로 다른 정책이 적용되기 쉽다. 프레임워크의 공통 CORS 설정을 사용해 허용 출처를 한곳에서 관리하는 것이 좋다.
취약한 Spring CORS 설정
다음과 같이 모든 출처 패턴과 모든 요청 방법을 허용하면서 자격 증명까지 활성화하는 설정은 위험하다.
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
allowedOriginPatterns("*")는 들어온 Origin과 일치하는 광범위한 패턴이다. 자격 증명까지 허용하면 신뢰하지 않은 웹페이지가 인증된 API 응답을 읽을 위험이 있다.
실제 서비스에 필요한 출처와 요청 방법을 확인하지 않고 오류 해결만을 위해 적용한 설정은 운영 환경에 배포해서는 안 된다.
안전한 Spring CORS 설정 방법
Spring Security 환경에서는 허용할 출처, 메서드와 헤더를 명확하게 지정한 CorsConfigurationSource를 사용할 수 있다.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config =
new CorsConfiguration();
config.setAllowedOrigins(
List.of(
"https://app.example.com"
)
);
config.setAllowedMethods(
List.of(
"GET",
"POST",
"PUT",
"DELETE"
)
);
config.setAllowedHeaders(
List.of(
"Content-Type",
"Authorization",
"X-CSRF-TOKEN"
)
);
config.setExposedHeaders(
List.of(
"X-Request-Id"
)
);
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration(
"/api/**",
config
);
return source;
}
Spring Security 설정에서도 CORS 처리를 활성화한다.
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http) throws Exception {
http.cors(
Customizer.withDefaults()
);
return http.build();
}
이 설정은 https://app.example.com만 허용한다. HTTP로 접속하거나 호스트와 포트가 다른 출처는 별도의 허용 목록에 없는 한 응답을 읽을 수 없다.
요청 방법과 헤더도 서비스에서 실제로 사용하는 범위로 제한한다. 모든 메서드와 헤더를 허용하는 것보다 정책을 이해하고 점검하기 쉽다.
allowCredentials(true)는 로그인 쿠키나 HTTP 인증 정보가 필요한 경우에만 사용해야 한다. 공개 API라면 자격 증명 없이 필요한 출처 범위만 허용하는 편이 안전하다.
여러 출처를 허용하는 방법
운영 서비스와 관리자 화면처럼 여러 출처가 필요하다면 정확한 주소를 각각 등록할 수 있다.
config.setAllowedOrigins(
List.of(
"https://app.example.com",
"https://admin.example.com"
)
);
Access-Control-Allow-Origin 응답 헤더에 여러 주소를 쉼표로 나열하는 방식은 사용하지 않는다. 서버는 요청의 Origin이 허용 목록에 있는지 확인하고 해당 요청에 맞는 하나의 출처를 응답해야 한다.
Origin 값을 동적으로 반환하는 경우 캐시가 다른 출처에 대한 응답을 재사용하지 않도록 다음 헤더도 고려해야 한다.
Vary: Origin
Spring과 같은 프레임워크가 CORS 응답을 생성할 때 이를 처리할 수 있지만 프록시와 CDN을 포함한 실제 응답에서 정상적으로 적용되는지 확인하는 것이 좋다.
개발 환경의 http://localhost 주소가 필요하다면 운영 설정과 분리해야 한다. 개발용 출처가 운영 서버의 허용 목록에 남지 않도록 프로필이나 환경변수를 이용해 관리할 수 있다.
Preflight 요청이란?
브라우저는 일부 교차 출처 요청을 바로 보내지 않고 먼저 OPTIONS 요청으로 서버의 허용 여부를 확인한다. 이를 Preflight 요청 또는 사전 요청이라고 한다.
예를 들어 JSON 데이터를 PUT 요청으로 보내거나 사용자 정의 헤더를 포함하면 브라우저는 다음과 같은 정보를 확인할 수 있다.
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type
서버는 허용 가능한 요청이라면 다음과 같은 CORS 헤더로 응답한다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type
Preflight 요청이 인증 필터에서 차단되면 정상적인 브라우저 요청이 실패할 수 있다. 그렇다고 모든 OPTIONS 요청과 출처를 무조건 허용해서는 안 된다. CORS 정책에 따라 Origin, 메서드와 헤더를 검증한 후 처리해야 한다.
Access-Control-Max-Age를 설정하면 브라우저가 사전 요청 결과를 일정 시간 저장해 반복되는 OPTIONS 요청을 줄일 수 있다. 너무 긴 시간을 적용하면 정책 변경이 브라우저에 늦게 반영될 수 있으므로 서비스 특성에 맞게 설정한다.
CORS와 쿠키 자격 증명
브라우저의 JavaScript에서 쿠키를 포함한 교차 출처 요청을 보내려면 클라이언트와 서버 양쪽 설정이 필요하다.
클라이언트는 fetch 요청에서 다음과 같이 자격 증명 사용을 지정할 수 있다.
fetch(
"https://api.example.com/profile",
{
credentials: "include"
}
);
서버는 정확한 Origin을 허용하고 Access-Control-Allow-Credentials: true를 반환해야 한다.
쿠키의 SameSite, Secure와 도메인 설정도 실제 전송 여부에 영향을 준다. CORS에서 자격 증명을 허용했다고 해서 모든 쿠키가 자동으로 전송되는 것은 아니다.
자격 증명 요청을 허용할 때는 출처 목록을 특히 엄격하게 관리해야 한다. 사용하지 않는 테스트 도메인이나 관리되지 않는 하위 도메인이 남아 있지 않은지 주기적으로 확인해야 한다.
CORS는 인증과 접근통제가 아니다
CORS는 브라우저의 JavaScript가 응답을 읽는 것을 제한하는 정책이다. curl, 서버 프로그램이나 모바일 앱처럼 브라우저의 동일 출처 정책을 적용받지 않는 클라이언트는 CORS 헤더와 관계없이 API 요청을 보낼 수 있다.
따라서 민감한 API에는 로그인 인증과 사용자별 권한 검사가 반드시 필요하다. 관리자 API는 관리자 계정인지 확인하고 일반 사용자는 자신의 데이터에만 접근할 수 있도록 서버에서 검증해야 한다.
CORS 오류가 발생한다는 이유로 인증 필터나 권한 검사를 제거해서는 안 된다. 브라우저의 교차 출처 허용 문제와 서버의 접근통제 문제는 별도로 다뤄야 한다.
CORS와 CSRF의 차이점
CORS가 설정돼 있다고 해서 CSRF 공격이 자동으로 차단되는 것은 아니다. CORS는 주로 응답을 읽을 수 있는지를 제어한다. 일부 단순 요청은 응답을 읽지 못하더라도 서버까지 전송될 수 있다.
CSRF는 로그인 사용자의 쿠키를 이용해 상태 변경 요청을 보내는 공격이다. 게시물 삭제, 정보 변경과 결제처럼 중요한 기능에는 CSRF 토큰과 SameSite 쿠키를 적용해야 한다.
CORS는 신뢰할 출처에서의 정상적인 데이터 공유를 담당하고, CSRF 방어는 사용자의 의도와 요청의 정당성을 확인하는 역할을 한다. 두 기능을 각각 올바르게 설정해야 한다.
CORS 오류 메시지가 나타나는 이유
브라우저 개발자 도구에 CORS 오류가 표시되는 이유는 다양하다. 서버가 Access-Control-Allow-Origin을 반환하지 않았거나 요청 Origin과 허용 출처가 다를 수 있다.
Preflight 요청이 인증 또는 방화벽에 차단됐을 수도 있다. 요청한 메서드와 헤더가 Access-Control-Allow-Methods 또는 Access-Control-Allow-Headers에 포함되지 않은 경우에도 오류가 발생한다.
서버에서 정상 응답을 보냈더라도 브라우저가 CORS 정책에 따라 JavaScript에 응답 내용을 제공하지 않을 수 있다. Postman이나 curl에서는 정상인데 브라우저에서만 실패하는 이유도 이 차이 때문이다.
오류를 해결할 때 모든 출처를 허용하기보다 요청 Origin, 메서드, 헤더와 자격 증명 사용 여부를 하나씩 확인해야 한다.
CORS 설정 오류 점검 항목
CORS를 점검할 때는 먼저 민감한 API의 응답에서 Access-Control-Allow-Origin 값을 확인한다. 요청한 임의의 Origin이 그대로 응답되는지, 별표가 불필요하게 사용되는지 살펴봐야 한다.
Access-Control-Allow-Credentials: true가 설정된 API는 허용 출처를 더욱 엄격하게 확인해야 한다. 모든 Origin을 반사하거나 광범위한 정규표현식으로 허용하지 않는지 점검한다.
HTTP와 HTTPS, 호스트와 포트를 정확하게 구분하는지도 확인한다. 더 이상 사용하지 않는 개발 도메인과 하위 도메인이 허용 목록에 남아 있지 않아야 한다.
허용 메서드와 헤더가 실제 업무 범위보다 넓지 않은지, Preflight 응답에도 동일한 정책이 적용되는지 살펴본다. CDN이나 프록시가 Origin별 응답을 잘못 캐시하지 않도록 Vary: Origin 처리도 확인할 필요가 있다.
자주 묻는 질문
Access-Control-Allow-Origin에 별표를 사용하면 모두 취약한가요?
아니다. 누구에게나 공개하는 API이고 쿠키와 같은 자격 증명을 사용하지 않는다면 별표를 사용할 수 있다. 개인정보나 인증된 사용자 데이터에는 정확한 허용 출처를 지정해야 한다.
CORS를 설정하면 외부에서 API를 호출하지 못하나요?
그렇지 않다. CORS는 브라우저 JavaScript의 응답 읽기를 제한한다. 서버 프로그램, curl과 모바일 앱은 CORS 정책의 직접적인 제한을 받지 않으므로 서버의 인증과 접근통제가 필요하다.
Postman에서는 되는데 브라우저에서 실패하는 이유는 무엇인가요?
Postman은 브라우저의 동일 출처 정책을 적용받지 않는다. 브라우저에서는 서버가 올바른 CORS 응답 헤더를 제공해야 교차 출처 응답을 JavaScript에서 읽을 수 있다.
CORS를 설정하면 CSRF 토큰은 필요 없나요?
필요하다. CORS와 CSRF는 목적이 다르다. 상태를 변경하는 요청에는 CSRF 토큰과 SameSite 쿠키를 적용하고, CORS는 신뢰하는 출처에만 응답 읽기를 허용하도록 설정해야 한다.
여러 도메인을 하나의 헤더에 나열해도 되나요?
Access-Control-Allow-Origin에는 요청에 대해 허용할 하나의 출처를 반환해야 한다. 서버에서 요청 Origin이 허용 목록에 있는지 확인한 뒤 일치하는 값을 응답하도록 구성한다.
마무리
CORS 설정 오류는 신뢰하지 않은 출처에 API 응답을 읽을 권한을 부여할 때 발생한다. 특히 요청의 Origin을 그대로 반환하면서 자격 증명까지 허용하면 로그인 사용자의 정보가 외부 웹페이지에 노출될 수 있다.
안전한 설정을 위해서는 실제 서비스를 제공하는 프로토콜, 호스트와 포트를 정확하게 허용 목록으로 관리해야 한다. 요청 메서드와 헤더도 필요한 범위로 제한하고 공개 API와 인증 API의 정책을 분리하는 것이 좋다.
CORS 보안의 핵심은 모든 교차 출처 요청을 차단하는 것이 아니라 신뢰할 수 있는 출처만 정확하게 허용하는 것이다. CORS 설정과 별도로 인증, 접근통제, CSRF 방어와 쿠키 보안 속성도 함께 적용해야 한다.
핵심 키워드: CORS 설정 오류, Access-Control-Allow-Origin, 교차 출처 리소스 공유, CORS 보안 설정, CORS 취약점, Preflight 요청, Access-Control-Allow-Credentials, Spring CORS 설정