이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube CSRF 룰 S4502가 Security 설정 파일에서 12건이 뜬 거다. Spring Boot 3 + Spring Security 6 마이그레이션을 진행 중이었고, SonarQube SQL Injection(S3649)과 XSS(S5131)를 정리하고 나서 Security 설정을 손대기 시작했는데 이번엔 CSRF 차례였다. “Disabling CSRF protections is security-sensitive”라는 메시지였다.
레거시 Spring Security 5 코드에서 http.csrf().disable()을 남발한 게 원인이었다. 금융권 SI 특성상 내부 REST API가 많았고, 당시 개발자가 “어차피 내부 API니까 CSRF 필요 없다”는 판단으로 전체를 꺼버린 거다. Spring Security 6에서는 람다 DSL로 바뀌면서 csrf(csrf -> csrf.disable()) 형태가 됐는데, SonarQube CSRF 룰 관점에서는 여전히 같은 Hotspot이었다.
Security Hotspot은 Vulnerability와 달리 “위험할 수 있으니 검토하라”는 분류다. 자동 FAIL이 아니라 Reviewer가 “Reviewed as Safe”로 수동 처리할 수 있다. 그런데 우리 프로젝트 Quality Gate는 Hotspot을 100% reviewed 상태로 만들어야 통과 조건이라서 무시할 수가 없었다. 4가지 방향을 시도하다가 결국 토큰 저장소 교체 + JSP form 수정으로 마무리했다.

레거시에서 발견된 패턴
12건을 분류하니 두 패턴으로 나뉘었다.
패턴 1. SecurityConfig에서 전체 disable
// LegacySecurityConfig.java (Spring Security 5 스타일)
@Configuration
@EnableWebSecurity
public class LegacySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // S4502 여기서 잡힘
.authorizeRequests()
.antMatchers("/api/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/dashboard");
}
}
Spring Security 6으로 올리면서 WebSecurityConfigurerAdapter가 deprecated 됐고, 람다 DSL 방식의 SecurityFilterChain Bean으로 교체해야 했다. 교체 과정에서 csrf(csrf -> csrf.disable())로 그대로 옮겼는데, 형태만 바뀌었을 뿐 SonarQube CSRF 룰은 동일하게 잡았다.
패턴 2. JSP form에 CSRF 토큰 누락
<!-- payment-form.jsp — 이체 처리 폼 -->
<form method="post" action="/payment/transfer">
<input type="hidden" name="acctNo" value="${acctNo}" />
<input type="text" name="amount" />
<input type="text" name="targetAcct" />
<button type="submit">이체</button>
</form>
CSRF 보호가 켜져 있다면 <form> 안에 _csrf 토큰이 있어야 한다. CSRF를 disable해놨으니 토큰이 없어도 동작했는데, 이 조합 자체가 취약점이었다. SonarQube는 CSRF disable 코드를 보는 즉시 Hotspot으로 표시한다.
시도 1. csrf().disable() 그대로 두고 다른 곳 수정 — 실패
처음에는 CSRF disable을 건드리지 않고 “다른 보안 설정을 강화하면 Hotspot 상태를 Reviewed as Safe로 처리할 수 있지 않을까”라고 생각했다. 내부 API이고, JWT 토큰 기반 인증을 추가할 계획이었으니 CSRF 위험이 낮다는 논리였다.
// 시도 1 — CSRF disable은 그대로, JWT 필터 추가
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 그대로 둠
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated());
return http.build();
}
결과는 역시 S4502가 그대로였다. SonarQube CSRF 룰은 “disable() 호출이 존재하면 Hotspot” 그 자체다. 다른 보안 장치를 얼마나 추가해도 disable 코드가 있으면 사라지지 않는다. “Reviewed as Safe”로 수동 처리하는 방법도 있었는데, Quality Gate 정책상 CSRF 관련 Hotspot은 수동 승인 자체가 금지돼 있었다. 그 정책이 있다는 걸 이 시점에 처음 알았다. 허탈하더라.
시도 2. API 엔드포인트만 disable — 통과는 됐지만 위험
두 번째 방향은 “전체 disable 대신, REST API 경로만 disable하면 Hotspot이 사라지지 않을까”였다. Spring Security 6에서는 securityMatcher로 특정 요청 패턴에 다른 필터 체인을 적용할 수 있다.
// 시도 2 — API 전용 SecurityFilterChain
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // API만 disable
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login"));
return http.build();
}
이 방식으로 /api/** 체인의 S4502 Hotspot은 그대로였다. SonarQube는 securityMatcher로 범위를 좁혔어도 disable 호출 자체를 잡는다. Stateless JWT 환경에서 CSRF를 꺼도 실제 보안상 문제는 없다는 주장도 있지만, Hotspot은 사라지지 않았다.
더 큰 문제는 웹 폼 체인(/api/** 이외 경로)도 기본적으로 CSRF가 켜진 상태인데, JSP form에서 토큰을 전송하지 않으니 이체 폼이 403을 뱉기 시작했다. 분리를 잘못 설계한 거였다. Path Traversal(S2083) 작업할 때도 느꼈는데, 보안 설정 변경은 “한 부분만 고치면 된다”는 생각이 항상 문제를 만든다.
시도 3. ignoringRequestMatchers — 부분 통과
세 번째 시도는 전체 disable 대신 ignoringRequestMatchers로 특정 경로만 CSRF 검사를 예외 처리하는 방식이었다. 이 방법을 쓰면 csrf().disable() 호출이 없어지니까 S4502가 사라질 거라고 기대했다.
// 시도 3 — ignoringRequestMatchers로 API 경로 예외
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/payment/**", "/api/account/**", "/api/batch/**")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login"));
return http.build();
}
S4502 Hotspot이 사라졌다. 여기서 멈출 수도 있었는데, 문제가 두 가지 남았다.
첫째, ignoringRequestMatchers로 예외 처리한 경로가 12개였다. 경로가 늘어날 때마다 이 목록을 수동 관리해야 한다. 배치 API 신규 추가 때 빠뜨리면 403이 뜨고, 반대로 웹 폼 경로가 실수로 들어가면 CSRF 검사가 빠진다.
둘째, JSP 폼에 CSRF 토큰이 여전히 없었다. CSRF 보호가 켜진 경로의 POST form은 토큰 없이 제출하면 전부 403이었다. Command Injection(S2076)처럼 데이터 플로우를 끊는 방식이 아니라, 실제로 form 태그를 전부 수정해야 한다는 걸 이때 받아들였다.
최종 해결 — CookieCsrfTokenRepository + JSP 토큰 삽입
4단계로 나눠서 처리했다. CSRF 보호를 완전히 켜되, SPA 또는 일부 내부 API는 ignoringRequestMatchers로만 예외 처리하고, JSP form은 전부 토큰을 삽입하는 방식이다.
1단계 — CookieCsrfTokenRepository 설정
// FinSecurityConfig.java
@Configuration
@EnableWebSecurity
public class FinSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF: 쿠키 기반 토큰 저장소 사용 (SPA에서 X-XSRF-TOKEN 헤더로 전송 가능)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// 순수 REST API (Stateless JWT) 경로만 예외
.ignoringRequestMatchers("/api/v1/batch/**", "/api/v1/webhook/**")
)
// 세션 기반 웹 UI 경로
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/error", "/css/**", "/js/**", "/images/**").permitAll()
.requestMatchers("/api/v1/batch/**", "/api/v1/webhook/**").hasRole("SYSTEM")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error")
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
}
CookieCsrfTokenRepository.withHttpOnlyFalse()는 CSRF 토큰을 XSRF-TOKEN 쿠키에 저장하고, JavaScript에서 읽을 수 있게 HttpOnly=false로 설정한다. SPA 프론트엔드가 있다면 쿠키에서 토큰을 읽어 X-XSRF-TOKEN 헤더로 보내면 된다. JSP 기반 폼에서는 숨김 필드로 삽입하는 방식을 쓴다.
2단계 — JSP form에 CSRF 토큰 삽입
JSP에서 Spring Security의 CSRF 토큰을 삽입하는 방법은 두 가지다.
<%-- 방법 A: Spring Security JSP 태그 라이브러리 -->
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
<form method="post" action="/payment/transfer">
<sec:csrfInput />
<input type="hidden" name="acctNo" value="${acctNo}" />
<input type="text" name="amount" placeholder="이체 금액" />
<input type="text" name="targetAcct" placeholder="수취 계좌" />
<button type="submit">이체</button>
</form>
<%-- 방법 B: EL 표현식 직접 삽입 -->
<form method="post" action="/payment/transfer">
<input type="hidden"
name="${_csrf.parameterName}"
value="${_csrf.token}" />
<input type="hidden" name="acctNo" value="${acctNo}" />
<input type="text" name="amount" />
<button type="submit">이체</button>
</form>
<sec:csrfInput />가 더 깔끔한데, 프로젝트에 spring-security-taglibs 의존성이 없으면 taglib 선언 자체가 에러를 뱉는다. 레거시 프로젝트엔 의존성이 빠져 있는 경우가 많아서, 방법 B인 EL 표현식 방식으로 통일했다.
3단계 — AJAX POST 요청에 헤더 삽입
JSP 안에 jQuery를 쓰는 AJAX 호출도 있었다. form submit이 아닌 XHR/fetch 방식은 헤더로 토큰을 보내야 한다.
// 공통 JS 파일에 한 번만 설정 (layout 또는 header JSP에 포함)
// CSRF 토큰을 meta 태그에서 읽는 방식
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
// jQuery AJAX 전역 설정
if (csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function(xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
// fetch 방식
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify(body)
});
return res.json();
}
header JSP에 meta 태그를 추가해야 한다.
<%-- header.jsp — head 태그 안 -->
<meta name="_csrf" content="${_csrf.token}" />
<meta name="_csrf_header" content="${_csrf.headerName}" />
4단계 — 검증 및 에러 처리
CSRF 토큰 불일치 시 Spring Security는 기본적으로 403을 반환한다. 금융권 프로젝트라 사용자 경험보다 보안 에러 페이지가 중요했다. AccessDeniedHandler를 커스텀해서 CSRF 에러와 일반 권한 에러를 구분했다.
// CsrfAccessDeniedHandler.java
@Component
public class CsrfAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req,
HttpServletResponse res,
AccessDeniedException ex) throws IOException {
if (ex instanceof InvalidCsrfTokenException
|| ex instanceof MissingCsrfTokenException) {
// CSRF 토큰 만료 또는 누락 — 로그인 페이지로 리다이렉트
res.sendRedirect("/login?csrfExpired");
} else {
// 일반 권한 없음
res.sendRedirect("/error/403");
}
}
}
// SecurityConfig에 handler 등록
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/v1/batch/**", "/api/v1/webhook/**")
)
.exceptionHandling(ex -> ex
.accessDeniedHandler(csrfAccessDeniedHandler)
);
이 4단계를 모두 적용하고 나서 SonarQube를 다시 돌렸다. S4502 Hotspot 12건이 전부 사라졌다. 핵심 포인트 4가지를 정리하면 아래와 같다.
- csrf().disable() 호출 자체를 제거 — disable이 있으면 범위를 좁혀도 Hotspot은 사라지지 않는다
- CookieCsrfTokenRepository.withHttpOnlyFalse() — 쿠키 기반 토큰 저장소로 JSP + SPA 혼용 환경 모두 대응
- JSP form마다
${_csrf.token}삽입 — EL 표현식 방식이 taglib 의존성 없이 바로 쓸 수 있어서 레거시에 적합 - AJAX는 meta 태그 +
X-CSRF-TOKEN헤더 — jQuery, fetch 모두 전역 설정 한 번으로 처리
정리
SonarQube CSRF(S4502)의 본질은 CSRF 보호를 켜고, JSP form과 AJAX 요청에 토큰을 함께 보내는 것이다. disable()을 그대로 두거나 다른 보안 설정으로 우회하려는 시도는 Hotspot을 제거하지 못한다. 경로 예외가 필요하다면 ignoringRequestMatchers로 범위를 명시하고, 나머지는 토큰을 정상적으로 흘려줘야 한다.
이 시리즈는 S2755 XXE, S5131 XSS 편과 연결된다. 다음 편은 SonarQube Weak Cryptography 룰 S4790을 다룬다 — 레거시 MD5 비밀번호 해싱에서 BCrypt까지 마이그레이션한 과정이다.
룰 본문은 SonarSource 공식 룰 페이지(S4502)에서 확인할 수 있다.