이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, 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은 전부 사라졌다. 한 군데 덜 적용된 곳은 룰이 정확히 그 줄을 다시 잡아줘서 누락도 없다.
정리
SonarQube XSS 룰 대응의 본질은 두 가지다. 첫째, 자체 구현 escape는 인정 안 된다 — 알려진 라이브러리를 써야 한다. 둘째, 입력이 아니라 출력 시점에, 컨텍스트별로 인코딩한다. 이 둘만 지키면 S5131은 깔끔하게 사라진다.
이전 편 SonarQube Command Injection (S2076)이 셸 인터프리터 입력 문제였다면, XSS는 브라우저 인터프리터 출력 문제다. 본질은 같다. 다음 편에서는 파일 시스템 인터프리터 격인 SonarQube Path Traversal (S2083)을 다룬다.
룰 본문은 SonarSource 공식 룰 페이지(S5131)에서 확인할 수 있다. OWASP Java Encoder는 OWASP 공식 페이지를 참고하면 된다.