이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·도메인명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube CORS 룰 S5122는 Spring CORS 설정을 작성하자마자 떴다. JDK 21 업그레이드 작업이 어느 정도 마무리될 무렵, 이번엔 사내 SPA 프론트엔드 팀에서 연락이 왔다. React 앱이 별도 도메인(front.finco.internal)으로 분리되면서 Spring Boot 백엔드 API에 CORS 정책을 열어달라는 거였다. API 서버는 api.finco.internal에 있었고, 두 도메인이 달랐다. 이미 SonarQube CSRF(S4502)를 다루면서 Security Hotspot 대응 경험이 좀 쌓였으니 빠르게 끝낼 수 있을 거라고 생각했다.
CORS 설정 코드를 작성하고 커밋했더니 바로 SonarQube CORS 룰 S5122가 Security Hotspot으로 떴다. 메시지는 “Having a permissive Cross-Origin Resource Sharing policy is security-sensitive”. CWE-942에 해당하는 룰이다. 프론트엔드 팀 요청 대로 빠르게 열어준다고 setAllowedOrigins(List.of("*"))로 써놓은 게 문제였다. SonarQube CORS 룰은 여기서 끝나지 않았고, @CrossOrigin 어노테이션이 컨트롤러 여기저기 붙어있는 것도 전부 잡았다.
Security Hotspot이라 자동 fail은 아니지만, 보안 리뷰 게이트가 걸려 있어서 통과 처리를 받으려면 코드를 손봐야 했다. 세 번 시도했다.

발견된 패턴 — 와일드카드 + credentials
SonarQube가 잡은 코드는 두 군데였다. 하나는 글로벌 CORS 설정, 다른 하나는 컨트롤러에 흩뿌려진 @CrossOrigin이었다.
패턴 1. CorsConfiguration 와일드카드 + credentials 조합
이게 S5122에서 가장 위험하다고 분류되는 조합이다. setAllowedOrigins(List.of("*"))와 setAllowCredentials(true)를 같이 쓰면 SonarQube가 즉시 잡는다. 사실 Spring 자체도 이 조합을 런타임에 거부하는데, 그 에러를 보기 전에 SonarQube가 먼저 잡아준 셈이다.
// FinCorsConfig.java — 취약 패턴
@Configuration
public class FinCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*") // S5122 — 여기서 잡힘
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true) // 와일드카드 + credentials = 최악의 조합
.maxAge(3600);
}
}
Spring은 내부적으로 allowCredentials(true)와 allowedOrigins("*")를 같이 쓰면 IllegalArgumentException을 던진다. “When allowCredentials is true, allowedOrigins cannot contain the special value ‘*'” 메시지가 나온다. 런타임 에러도 나는데 SonarQube도 잡으니 이건 어떻게든 고쳐야 했다.
패턴 2. @CrossOrigin(origins = “*”) 무분별 사용
컨트롤러 개발 초기에 빠르게 프론트엔드 연동 테스트하려고 붙여놓은 어노테이션들이 그대로 남아 있었다. 9개 컨트롤러에서 발견됐다.
// FinAccountController.java — 취약 패턴
@RestController
@RequestMapping("/api/v1/accounts")
@CrossOrigin(origins = "*") // S5122 — Hotspot
public class FinAccountController {
@GetMapping("/{accountId}")
public ResponseEntity<AccountDto> getAccount(@PathVariable String accountId) {
// ...
}
@PostMapping("/transfer")
@CrossOrigin(origins = "*", allowedHeaders = "*") // 메서드 레벨에도 있었음
public ResponseEntity<TransferResult> transfer(@RequestBody TransferRequest req) {
// ...
}
}
개발 편의용으로 붙인 거라는 걸 다들 알고 있었는데, 운영 코드에 그대로 커밋돼 버렸다. 하나씩 제거하고 글로벌 설정으로 통합해야 했다. SonarQube Hardcoded Credentials(S2068) 때도 개발 편의용 코드가 그대로 올라가는 패턴이 반복됐는데, CORS도 똑같았다.
시도 1. null origin 차단 — 실패
첫 번째 시도는 “null origin을 명시적으로 차단하면 되지 않을까”였다. CORS 관련 글을 찾아보다가 null origin 공격 벡터 얘기가 나와서, allowedOrigins에서 "null"을 제외하면 Hotspot이 해소될 거라고 생각했다.
// 시도 1 — null origin 차단
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*") // 여전히 와일드카드
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.exposedHeaders("Authorization")
.maxAge(3600);
// allowCredentials는 일단 뺐음
}
결과는 여전히 S5122가 떠 있었다. SonarQube CORS 룰이 잡는 건 “null origin”이 들어올 수 있냐의 문제가 아니라, 어떤 origin도 허용하는 와일드카드 정책 자체가 security-sensitive하다는 거다. null origin 차단은 본질을 건드리지 않았다. 20분 날렸다.
게다가 allowCredentials를 제거했더니 프론트엔드에서 401이 쏟아지기 시작했다. 인증 쿠키가 요청에 실려가야 하는 API들이 있었는데, credentials 없이는 동작이 안 됐다. 결국 credentials도 다시 붙여야 했다.
시도 2. origin 패턴 검증 — 부분 통과
두 번째 시도는 Spring 5.3에서 추가된 setAllowedOriginPatterns였다. allowedOrigins("*") 대신 allowedOriginPatterns("*")를 쓰면 credentials와 함께 쓸 수 있다는 걸 Spring 공식 문서에서 확인했다.
// 시도 2 — allowedOriginPatterns 사용
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*") // allowedOrigins("*") 대신
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true) // 이제 런타임 에러는 안 남
.maxAge(3600);
}
Spring 5.3 이상에서는 allowedOriginPatterns("*")가 allowCredentials(true)와 공존할 수 있다. 런타임 에러는 사라졌고, 프론트엔드 연동도 됐다. 그런데 SonarQube는 여전히 Hotspot을 남겨뒀다. SonarQube CORS 룰 S5122가 패턴 매칭까지 추적하기 때문이었다. 분석기 입장에서 allowedOriginPatterns("*")는 결과적으로 어떤 origin이든 허용하는 거라 경고를 남기는 게 맞다.
Security Hotspot “통과 처리”를 받으려면 리뷰어가 “이 코드는 의도적으로 열어둔 거고, 리스크를 알고 있다”고 Reviewed 처리해주면 된다. 팀 리드한테 물어봤더니 “그냥 닫으면 감사에서 문제될 수 있으니 코드를 고쳐라”고 했다. 어쩔 수 없이 세 번째 방법으로 갔다.
시도 3. 화이트리스트 + credentials 분리 — 최종
본질은 단순하다. 어떤 origin이든 허용하는 게 아니라, 허용할 origin 목록을 명시적으로 지정하면 된다. 그리고 credentials가 필요한 경로와 그렇지 않은 경로를 분리해서 설정한다.
환경별로 허용 origin이 다를 수 있으니 application.properties로 외부화했다. 이 패턴은 SonarQube Insecure Cookie(S2092)에서 쿠키 설정을 환경별로 분리한 것과 같은 원리다.
# application-local.properties
cors.allowed-origins=http://localhost:3000,http://localhost:5173
# application-dev.properties
cors.allowed-origins=https://front-dev.finco.internal
# application-prod.properties
cors.allowed-origins=https://front.finco.internal
// FinCorsConfig.java — 최종 해결 (CorsConfigurationSource 방식)
@Configuration
public class FinCorsConfig {
@Value("${cors.allowed-origins}")
private String allowedOriginsRaw;
/**
* Spring Security와 함께 쓸 경우 WebMvcConfigurer.addCorsMappings 대신
* CorsConfigurationSource 빈을 등록해야 Security Filter Chain에서도 적용된다.
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 화이트리스트 — 절대 "*" 사용하지 않음
List<String> origins = Arrays.asList(allowedOriginsRaw.split(","));
config.setAllowedOrigins(origins);
config.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
));
config.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With",
"X-CSRF-TOKEN" // CSRF 토큰 헤더도 허용
));
config.setExposedHeaders(Arrays.asList("Authorization"));
// credentials가 필요한 경로에만 true
// 여기서는 /api/** 전체에 credentials를 허용하지만,
// 공개 API가 있다면 별도 UrlBasedCorsConfigurationSource에 패턴별로 분리
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
// 공개 endpoint는 credentials 없이 더 넓게 열어도 됨
CorsConfiguration publicConfig = new CorsConfiguration();
publicConfig.setAllowedOriginPatterns(List.of("*")); // 공개 API는 패턴 허용
publicConfig.setAllowedMethods(List.of("GET", "OPTIONS"));
publicConfig.setAllowCredentials(false); // credentials 없음 — S5122 통과
source.registerCorsConfiguration("/public/**", publicConfig);
return source;
}
}
// FinSecurityConfig.java — Spring Security와 연동
@Configuration
@EnableWebSecurity
public class FinSecurityConfig {
@Autowired
private CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
// CSRF는 S4502 기준으로 이미 적용된 설정 유지
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
이걸 커밋하고 SonarQube 스캔을 돌렸더니 S5122 Hotspot이 전부 사라졌다. SonarQube CORS 분석기가 잡는 핵심은 두 가지였다.
allowedOrigins("*")와allowCredentials(true)조합 — 절대 같이 쓰지 말 것. Spring 자체도 런타임에 막는다.- 명시적 origin 목록 없이 전체 허용 —
allowedOriginPatterns("*")도 Hotspot으로 남는다. 리뷰어가 수동으로 닫지 않는 한.
@CrossOrigin 어노테이션은 컨트롤러에서 전부 제거했다. 글로벌 CorsConfigurationSource 빈이 있으면 컨트롤러별 어노테이션은 불필요하고, 어노테이션이 남아 있으면 충돌 가능성도 생긴다.
credentials가 꼭 필요한 경우 체크리스트
credentials를 열어야 한다면 다음 세 가지를 반드시 확인한다.
1. allowedOrigins 는 명시적 도메인 목록만 (와일드카드 금지)
config.setAllowedOrigins(List.of("https://front.finco.internal"));
2. allowedHeaders 는 필요한 것만 (Authorization, Content-Type, X-CSRF-TOKEN)
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN"));
3. exposedHeaders 는 클라이언트에서 읽어야 하는 것만
config.setExposedHeaders(List.of("Authorization"));
// 그리고 클라이언트 측 fetch/axios에서도 credentials 옵션 설정 필요
// fetch(url, { credentials: 'include' })
// axios.defaults.withCredentials = true;
사내 SPA처럼 도메인이 명확하게 고정된 경우에는 위 체크리스트가 충분하다. 멀티테넌트 SaaS처럼 서브도메인이 동적으로 생성되는 경우는 setAllowedOriginPatterns("https://*.finco.com")처럼 구체적인 패턴을 주고, credentials는 Reviewed 처리로 Hotspot을 닫는 게 현실적이다.
참고로 SonarQube Open Redirect(S5146)에서 URL 화이트리스트로 해결한 것과 같은 원리다 — 신뢰할 수 있는 대상 목록을 명시하고 그 외는 차단하는 것.
정리
SonarQube CORS(S5122)의 본질은 “누구든 접근 가능한 정책”이 credentials와 결합될 때 생기는 위험이다. 와일드카드 origin은 그 자체로 Security Hotspot이고, credentials와 결합되면 Spring조차 런타임에 거부할 만큼 위험하다. 허용할 origin을 명시적으로 지정하고, credentials가 필요없는 공개 API는 분리해서 관리하는 게 최종 해결이었다.
이 시리즈에서 다룬 CSRF(S4502)와 CORS는 서로 보완 관계다 — CSRF는 신뢰된 사용자가 악의적 사이트에서 요청을 보내는 걸 막고, CORS는 다른 도메인에서 브라우저가 응답을 읽는 걸 제어한다. 둘 다 놓치면 금융권 보안 감사에서 동시에 걸린다.
다음 편은 SonarQube Deserialization 룰 S5135를 다룬다 — ObjectInputStream.readObject()를 그대로 쓰다가 Vulnerability로 분류된 케이스다. CORS보다 고치기 훨씬 까다로웠다.
룰 본문은 SonarSource 공식 룰 페이지(S5122)에서 확인할 수 있다.