이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JDK 21 업그레이드 후 SonarQube XSS 룰 S5131이 화면 단에서 30건 넘게 떴다. 대부분 고객 검색, 거래 조회 결과 페이지처럼 사용자가 입력한 검색어를 그대로 다시 출력하는 reflected XSS 패턴이었다.
로직은 그대로 두고 SonarQube XSS 룰을 통과시키기 위해 3가지를 시도했다. 어떤 게 왜 안 됐는지, 최종 해결까지의 흐름을 정리한다.

레거시 JSP/Servlet 패턴
두 가지 케이스가 거의 전부였다.
패턴 1. JSP scriptlet 직접 출력
<%
String q = request.getParameter("q");
%>
<div>검색어: <%= q %></div>
패턴 2. Servlet에서 PrintWriter로 직접 합쳐 쓰기
String name = request.getParameter("name");
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<div>고객명: " + name + "</div>");
둘 다 외부 입력이 HTML 본문 컨텍스트로 그대로 흘러간다. ?q=<script>alert(1)</script>이면 즉시 실행이다. SonarQube XSS 분석기는 이 데이터 플로우를 잡는다.
시도 1. script 태그 블랙리스트 — 실패
String safe = q == null ? "" : q.replaceAll("(?i)<script.*?>.*?</script>", "");
%><div>검색어: <%= safe %></div>
S5131은 그대로다. <img src=x onerror=alert(1)>, <svg onload=...>, javascript: URL 등 우회는 무한히 많다. SonarQube XSS 룰은 알려진 인코더가 끼지 않으면 신뢰하지 않는다.
시도 2. HTML escape 자체 구현 — 실패
private static String escape(String s) {
if (s == null) return "";
return s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
HTML body 컨텍스트 한정으로는 문제가 거의 없다. 그런데 두 가지 이유로 룰이 안 사라진다.
- SonarQube XSS 분석기는 자체 구현 escape를 인코더로 인정하지 않는다. 알려진 라이브러리(OWASP Encoder, Spring HtmlUtils, JSTL
c:out등)를 봐야 sanitize로 간주한다. - 같은 값이 속성 값(
<input value="...">)이나 JS 컨텍스트(<script>var x = "...";</script>)에 들어가면 5문자 escape만으로는 부족하다.
시도 3. JSTL c:out 일괄 적용 — 부분 통과
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<div>검색어: <c:out value="${param.q}"/></div>
JSP 쪽은 이걸로 거의 다 사라졌다. c:out은 기본 escapeXml="true"라 SonarQube가 신뢰하는 sanitizer로 인식한다. 다만 두 케이스가 남았다.
- 속성 값 컨텍스트:
<input value="<c:out value='${param.q}'/>">— 본문 escape만 하면 따옴표 깨짐 또는 우회 여지 발생. - Servlet/Controller에서 직접 HTML을 합쳐 쓰는 코드는
c:out을 못 쓴다.
최종 해결 — OWASP Java Encoder + 컨텍스트별 인코딩
최종 정착시킨 형태는 OWASP Java Encoder를 도입하고, 출력 컨텍스트마다 다른 인코더를 쓰는 것이다. JSP/Servlet/Controller 어느 쪽이든 같은 라이브러리로 통일했다.
<!-- pom.xml -->
<dependency>
<groupId>org.owasp.encoder</groupId>
<artifactId>encoder</artifactId>
<version>1.3.1</version>
</dependency>
1) JSP — 컨텍스트별 EL 함수
<%-- 레거시 javax.servlet 환경 (Spring 5 / Tomcat 9 이하) --%>
<%@ taglib prefix="e" uri="https://www.owasp.org/index.php/OWASP_Java_Encoder_Project" %>
<%-- Jakarta Servlet 환경 (Spring 6 / Tomcat 10+, JDK 21에서 신규 모듈은 보통 이쪽) --%>
<%@ taglib prefix="e" uri="owasp.encoder.jakarta" %>
<!-- HTML body 컨텍스트 -->
<div>검색어: ${e:forHtml(param.q)}</div>
<!-- 속성 값 컨텍스트 -->
<input type="text" name="q" value="${e:forHtmlAttribute(param.q)}">
<!-- JS 컨텍스트 -->
<script>
var query = "${e:forJavaScript(param.q)}";
</script>
2) Servlet — Encoder 정적 메서드
import org.owasp.encoder.Encode;
String name = request.getParameter("name");
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<div>고객명: " + Encode.forHtml(name) + "</div>");
3) Spring Controller — 응답 단계에서 한 번만
@GetMapping("/search")
public String search(@RequestParam String q, Model model) {
model.addAttribute("query", q); // 그대로 전달
return "search"; // 뷰 단(Thymeleaf 또는 JSP)에서 인코딩
}
중요한 건 입력 시점이 아니라 출력 시점에, 출력 컨텍스트에 맞는 인코더로 처리한다는 점이다. 같은 사용자명도 HTML 본문에 들어갈 때, 속성에 들어갈 때, JS 안에 들어갈 때 각각 다른 인코딩이 필요하다. SonarQube XSS 룰은 알려진 인코더가 출력 직전에 끼면 그 흐름을 안전하다고 판단한다.
여기까지 적용하니 S5131은 전부 사라졌다. 한 군데 덜 적용된 곳은 룰이 정확히 그 줄을 다시 잡아줘서 누락도 없다.
놓치기 쉬운 추가 XSS 케이스
URL 파라미터 컨텍스트
링크 href에 외부 입력을 그대로 넣는 경우도 XSS 경로가 된다.
<!-- 위험한 패턴 -->
<a href="${param.redirect}">돌아가기</a>
?redirect=javascript:alert(1)을 넣으면 클릭 시 실행된다. URL 컨텍스트는 허용 prefix 검증과 속성 인코딩을 함께 써야 한다.
<%
String redirect = request.getParameter("redirect");
// javascript:, data: 같은 위험 스킴 차단 — 슬래시로 시작하는 내부 경로만 허용
if (redirect == null || !redirect.startsWith("/")) {
redirect = "/dashboard";
}
%>
<a href="<%=Encode.forHtmlAttribute(redirect)%>">돌아가기</a>
href 전체를 외부 입력으로 받으면 forHtmlAttribute로 속성 인코딩하고, 프로토콜 필터링도 함께 해야 javascript: 스킴 공격을 막을 수 있다.
CSP 헤더 추가 방어
OWASP Encoder로 코드 레벨 XSS를 막더라도 누락 지점이 생길 수 있다. Content Security Policy(CSP) 헤더는 브라우저 레벨에서 인라인 스크립트 실행 자체를 막는 추가 방어층이다.
// Spring MVC — Filter로 응답 헤더 일괄 추가
public class SecurityHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "SAMEORIGIN");
chain.doFilter(req, res);
}
}
CSP를 처음 도입할 때는 Content-Security-Policy-Report-Only 헤더로 먼저 모니터링 모드로 돌린다. 레거시에 인라인 스크립트가 많으면 CSP를 바로 적용하면 화면이 깨지므로 점진적으로 교체하면서 진행한다.
Thymeleaf 환경에서의 대응
Spring MVC + Thymeleaf 조합에서는 기본적으로 HTML 이스케이프가 자동으로 된다. 그러나 th:utext나 [(${var})] 언이스케이프 표현식을 쓰면 XSS가 그대로 열린다.
<!-- 안전 — 자동 이스케이프 -->
<div th:text="${userInput}"></div>
<!-- 위험 — 언이스케이프, 신뢰된 소스에만 -->
<div th:utext="${userInput}"></div>
<div>[(${userInput})]</div>
th:utext는 자체 생성 HTML처럼 신뢰된 소스에서 온 경우에만 써야 한다. 외부 입력이나 DB에서 가져온 값에는 절대 쓰지 않는다. SonarQube는 th:utext에 외부 입력이 흘러가는 패턴도 S5131로 잡는다.
JSP에서 Thymeleaf로 전환하는 마이그레이션 중에 c:out을 th:utext로 잘못 바꾸는 실수가 종종 발생한다. 마이그레이션 코드 리뷰 체크리스트에 이 항목을 넣어두면 누락을 방지할 수 있다.
정리
SonarQube XSS 룰 대응의 본질은 두 가지다. 첫째, 자체 구현 escape는 인정 안 된다 — 알려진 라이브러리를 써야 한다. 둘째, 입력이 아니라 출력 시점에, 컨텍스트별로 인코딩한다. 이 둘만 지키면 S5131은 깔끔하게 사라진다.
이전 편 SonarQube Command Injection (S2076)이 셸 인터프리터 입력 문제였다면, XSS는 브라우저 인터프리터 출력 문제다. 본질은 같다. 다음 편에서는 파일 시스템 인터프리터 격인 SonarQube Path Traversal (S2083)을 다룬다.
룰 본문은 SonarSource 공식 룰 페이지(S5131)에서 확인할 수 있다. OWASP Java Encoder는 OWASP 공식 페이지를 참고하면 된다.