이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube Insecure Cookie — S2092와 S3330이 세션 쿠키랑 remember-me 쿠키에서 한꺼번에 12건이 뜬 거다. S3649 SQL Injection을 87건 정리하고, S2755 XXE까지 다 잡았다고 한숨 돌렸던 시점이었다. 두 룰이 같은 라인에서 동시에 잡히니 처음에는 같은 룰인 줄 알았다. 다른 룰이다.
S2092는 Secure 플래그 누락이고, S3330은 HttpOnly 플래그 누락이다. 메시지도 각각 “Creating cookies without the ‘secure’ flag is security-sensitive”, “Creating cookies without the ‘httpOnly’ flag is security-sensitive”로 다르다. 둘 다 Security Hotspot 카테고리인데, CWE도 S2092는 CWE-614, S3330은 CWE-1004로 분리돼 있다. 짜증났던 건 S2092 하나 잡으면 S3330이 남고, S3330까지 잡으면 이번엔 SameSite 미설정이 마음에 걸렸다. SonarQube Insecure Cookie는 두 룰이 세트라는 걸 먼저 알았으면 시간을 덜 낭비했을 텐데.
이 글은 SonarQube Insecure Cookie 두 룰을 한 번에 통과시키는 3단계 흐름과, 거기서 발목을 잡은 javax Servlet API의 한계를 기록한 것이다.

레거시에서 발견된 패턴
12건을 들여다보니 두 패턴이 전부였다.
패턴 1. 세션 쿠키 — 플래그 미설정
// FinAuthFilter.java — 로그인 성공 후 세션 쿠키 발급
public class FinAuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String token = generateSessionToken(request);
// S2092, S3330 — 두 룰이 여기서 동시에 잡힘
Cookie sessionCookie = new Cookie("FIN_SESSION", token);
sessionCookie.setPath("/");
sessionCookie.setMaxAge(3600);
response.addCookie(sessionCookie);
chain.doFilter(req, res);
}
}
setSecure도 없고 setHttpOnly도 없다. 기본값이 false이기 때문에 SonarQube Insecure Cookie 룰은 이 쿠키를 두 가지 측면에서 잡는다. Secure 없으면 HTTP 평문 전송에서 세션 탈취 가능, HttpOnly 없으면 JavaScript에서 document.cookie로 쿠키 접근 가능이다.
패턴 2. remember-me 쿠키 — setSecure(false) 명시
// FinRememberMeService.java
public void setRememberMeCookie(HttpServletResponse response, String userId) {
String rememberToken = generateRememberToken(userId);
Cookie rememberCookie = new Cookie("FIN_REMEMBER", rememberToken);
rememberCookie.setPath("/");
rememberCookie.setMaxAge(60 * 60 * 24 * 30); // 30일
rememberCookie.setSecure(false); // S2092 — 명시적으로 false
// HttpOnly 설정 없음 — S3330
response.addCookie(rememberCookie);
}
이쪽은 더 심각하다. setSecure(false)를 명시적으로 써놨다. 개발 환경에서 HTTP로도 동작시키려고 임시로 넣었던 코드가 운영에까지 그대로 들어간 케이스다. SonarQube는 setSecure(false)를 명시적 취약 패턴으로 인식한다.
시도 1. setSecure(true)만 — S3330이 남는다
S2092 메시지가 “secure flag” 얘기를 하니까 일단 setSecure(true)부터 붙였다.
Cookie sessionCookie = new Cookie("FIN_SESSION", token);
sessionCookie.setPath("/");
sessionCookie.setMaxAge(3600);
sessionCookie.setSecure(true); // S2092 해소
response.addCookie(sessionCookie);
S2092는 사라졌다. 그런데 S3330이 그대로 떠 있다. 당연한 결과인데 처음엔 “이것도 같이 없어지겠지” 했다가 SonarQube UI에서 S3330만 남아있는 걸 보고 헛웃음이 나왔다. 두 룰은 별개다. setSecure(true)가 HttpOnly까지 커버하지 않는다.
remember-me 쪽도 setSecure(false)를 setSecure(true)로 바꿨는데, 역시 S3330이 남았다. SonarQube XSS(S5131) 때처럼 플래그 하나 고쳤다고 끝나는 게 아닌 거다.
시도 2. setSecure + setHttpOnly — 통과는 됐지만
setHttpOnly(true)를 추가했다.
// FinAuthFilter.java — 수정 버전
Cookie sessionCookie = new Cookie("FIN_SESSION", token);
sessionCookie.setPath("/");
sessionCookie.setMaxAge(3600);
sessionCookie.setSecure(true); // S2092 해소
sessionCookie.setHttpOnly(true); // S3330 해소
response.addCookie(sessionCookie);
// FinRememberMeService.java — 수정 버전
Cookie rememberCookie = new Cookie("FIN_REMEMBER", rememberToken);
rememberCookie.setPath("/");
rememberCookie.setMaxAge(60 * 60 * 24 * 30);
rememberCookie.setSecure(true);
rememberCookie.setHttpOnly(true);
response.addCookie(rememberCookie);
S2092와 S3330 둘 다 사라졌다. SonarQube Insecure Cookie 룰은 통과했다. 그런데 코드 리뷰에서 선임이 “SameSite는요?”라고 찍어줬다. SameSite=Lax 또는 SameSite=Strict 없이는 CSRF 공격에 대한 쿠키 레벨 방어가 없다는 거다. SonarQube가 잡지 않았다고 보안이 충분한 건 아니었다.
SameSite 미설정이 SonarQube 룰 위반은 아니지만, 금융권 보안 정책에서는 CSRF 방어 레이어로 SameSite 설정을 요구하는 경우가 많다. 어차피 고쳐야 했다.
시도 3. SameSite 설정 — javax 한계
javax.servlet.http.Cookie API에는 setSameSite() 메서드가 없다. Servlet 3.1(javax.servlet) 기준으로 Cookie 클래스는 setSecure, setHttpOnly, setMaxAge, setPath, setDomain까지는 있는데 SameSite를 다루는 표준 메서드가 없다.
첫 번째로 떠오른 건 Set-Cookie 헤더에 직접 SameSite 문자열을 붙이는 방식이었다.
// 시도 — 헤더 직접 추가
Cookie sessionCookie = new Cookie("FIN_SESSION", token);
sessionCookie.setPath("/");
sessionCookie.setMaxAge(3600);
sessionCookie.setSecure(true);
sessionCookie.setHttpOnly(true);
response.addCookie(sessionCookie);
// 별도로 Set-Cookie 헤더에 SameSite 붙이기 시도
response.addHeader("Set-Cookie",
"FIN_SESSION=" + token +
"; Path=/; MaxAge=3600; Secure; HttpOnly; SameSite=Lax");
문제가 생겼다. response.addCookie()가 이미 Set-Cookie: FIN_SESSION=... 헤더를 하나 붙이고, 그 뒤에 addHeader로 같은 이름의 쿠키를 또 붙이면 헤더가 두 개 나간다. 브라우저 동작은 나중에 온 걸 우선하지만, 헤더 중복이 생기는 것 자체가 지저분하다.
response.addCookie() 없이 헤더만 쓰면 되지 않나 싶었는데, 그러면 SonarQube가 다시 new Cookie() 생성 라인에서 잡을 가능성이 있었다. 실제로 테스트해봤더니 Cookie 객체를 만들고 addCookie를 호출하지 않는 패턴에서는 룰이 뜨지 않았다. 그러나 코드 가독성이 나빠지고, Tomcat 버전마다 헤더 직렬화 방식이 다를 수 있어서 찝찝했다.
최종 해결 — Set-Cookie 헤더 직접 + Spring Boot 설정
환경에 따라 두 가지 방법으로 나눠서 처리했다.
방법 A. Tomcat 9 이하 — addHeader로 완전한 Set-Cookie 작성
Tomcat 9(Servlet 4.0, javax.servlet)에서는 Cookie 객체 대신 헤더 직접 작성이 가장 깔끔하다.
// FinAuthFilter.java — Tomcat 9 최종 버전
public class FinAuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String token = generateSessionToken(request);
// Cookie 객체를 response에 넘기지 않고
// Set-Cookie 헤더를 직접 작성 — SameSite 포함 가능
String cookieHeader = buildSecureCookieHeader("FIN_SESSION", token, 3600);
response.addHeader("Set-Cookie", cookieHeader);
chain.doFilter(req, res);
}
private String buildSecureCookieHeader(String name, String value, int maxAge) {
return name + "=" + value
+ "; Path=/"
+ "; Max-Age=" + maxAge
+ "; Secure"
+ "; HttpOnly"
+ "; SameSite=Lax";
}
}
SonarQube는 new Cookie() 객체를 response.addCookie()로 추가하는 데이터 플로우를 추적한다. 헤더로 직접 쓰는 방식은 Cookie 객체 생성 자체가 없으므로 S2092, S3330 두 룰 모두 트리거되지 않는다.
remember-me도 동일하게 처리한다.
// FinRememberMeService.java — 최종 버전
public void setRememberMeCookie(HttpServletResponse response, String userId) {
String rememberToken = generateRememberToken(userId);
int maxAge = 60 * 60 * 24 * 30; // 30일
String cookieHeader = rememberToken == null ? null :
"FIN_REMEMBER=" + rememberToken
+ "; Path=/"
+ "; Max-Age=" + maxAge
+ "; Secure"
+ "; HttpOnly"
+ "; SameSite=Lax";
if (cookieHeader != null) {
response.addHeader("Set-Cookie", cookieHeader);
}
}
방법 B. Tomcat 10 / Servlet 6 — Cookie.setAttribute(“SameSite”, …)
Jakarta EE 9 기반의 Tomcat 10 이상(jakarta.servlet)에서는 Servlet 6 API가 제공되고, Cookie.setAttribute() 메서드가 추가됐다. 이 환경이라면 Cookie 객체 그대로 쓰면서도 SameSite를 설정할 수 있다.
// Tomcat 10+ / Jakarta Servlet 6 환경
import jakarta.servlet.http.Cookie;
Cookie sessionCookie = new Cookie("FIN_SESSION", token);
sessionCookie.setPath("/");
sessionCookie.setMaxAge(3600);
sessionCookie.setSecure(true);
sessionCookie.setHttpOnly(true);
sessionCookie.setAttribute("SameSite", "Lax"); // Servlet 6 신규 메서드
response.addCookie(sessionCookie);
레거시 환경은 대부분 Tomcat 9 이하라 방법 A로 처리했지만, 신규 프로젝트라면 방법 B가 훨씬 깔끔하다.
방법 C. Spring Boot — application.properties 일괄 설정
Spring Boot를 쓰고 있고 세션 쿠키를 Spring Security가 관리한다면, 코드보다 설정으로 처리하는 게 빠르다.
# application.properties (Spring Boot 2.6+)
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.name=FIN_SESSION
Spring Security 쪽 remember-me 쿠키는 RememberMeConfigurer에서 추가 설정이 필요하다.
// SecurityConfig.java (Spring Security 6, Spring Boot 3)
@Configuration
@EnableWebSecurity
public class FinSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.rememberMe(rememberMe -> rememberMe
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(60 * 60 * 24 * 30)
.useSecureCookie(true)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
);
return http.build();
}
}
Spring Security의 useSecureCookie(true)는 내부적으로 setSecure(true)와 setHttpOnly(true)를 모두 적용한다. SameSite는 application.properties의 server.servlet.session.cookie.same-site=lax로 Tomcat에서 처리한다.
핵심 3가지
- S2092 + S3330은 세트 — 하나만 고치면 나머지가 남는다.
setSecure(true)+setHttpOnly(true)를 항상 함께 적용한다 - javax.servlet Cookie에는 setSameSite가 없다 — Tomcat 9 이하에서는
addHeader("Set-Cookie", ...)로 직접 작성하거나, Tomcat 9context.xml의<CookieProcessor sameSiteCookies="lax"/>를 활용한다 - Spring Boot 사용 시 설정 우선 —
application.properties네 줄로 세션 쿠키의 Secure/HttpOnly/SameSite를 일괄 적용할 수 있다
Tomcat 9 context.xml에서 CookieProcessor를 설정하는 방법도 있다.
<!-- Tomcat 9 conf/context.xml -->
<Context>
<CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor"
sameSiteCookies="lax" />
</Context>
이 설정으로 Tomcat이 response.addCookie()로 나가는 모든 쿠키에 자동으로 SameSite=Lax를 붙여준다. 단, Tomcat 9.0.28 이상에서만 동작한다.
정리
SonarQube Insecure Cookie의 본질은 쿠키를 만들 때 Secure와 HttpOnly를 기본값인 false로 남겨두는 것이다. S2092와 S3330은 별개 룰이라 하나만 고치면 나머지가 남는다. 두 룰을 한 번에 처리하되, SameSite까지 챙겨야 CSRF 방어가 완성된다. javax.servlet Cookie API에는 SameSite 메서드가 없어서 Tomcat 9 이하라면 헤더 직접 작성 또는 context.xml CookieProcessor 설정으로 우회해야 한다.
이번 작업으로 Path Traversal(S2083)이나 XXE(S2755) 처럼 데이터 플로우를 끊는 방식과는 결이 달랐다. 쿠키는 플래그 설정만으로 끝나는 게 아니라, SameSite까지 챙겨야 CSRF 방어가 완성된다. 쿠키의 SameSite=Lax는 CSRF 방어의 쿠키 레이어이고, Spring Security의 CSRF 토큰은 폼 레이어다. 둘을 같이 쓰는 게 맞다.
다음 편은 SonarQube Stored XSS(S5131) — 댓글 시스템에서 DB 경유로 터진 케이스다. 35번에서 다뤘던 Reflected XSS와 이름은 같은 S5131인데 시나리오가 다르다.
룰 본문은 SonarSource 공식 룰 페이지(S2092)와 SonarSource 공식 룰 페이지(S3330)에서 확인할 수 있다.