SonarQube Stored XSS 빠르게 잡기 — DB 출력 3가지 패턴

이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.

SonarQube Stored XSS — 이게 이번 스프린트에서 날 잡아먹은 거다. 35번에서 SonarQube XSS 룰 S5131을 OWASP Java Encoder로 다 잡았다고 생각했다. 검색어 직접 출력, Servlet PrintWriter 직접 합치기 — reflected 패턴을 전부 정리하고 나서 한숨 돌렸는데, 이번엔 게시판 댓글 시스템 리뉴얼 작업에서 같은 S5131이 또 떴다. 룰 ID는 동일한데 시나리오가 완전히 달랐다.

솔직히 말하면, SonarQube Stored XSS라는 별도 룰이 있는 게 아니다. S5131 하나가 reflected와 stored를 모두 잡는다. SonarQube taint analysis는 데이터 플로우만 본다 — 외부 입력이 인코딩 없이 출력 단으로 흘러가면 그게 reflected든 stored든 같은 S5131이 뜬다. 공식 룰 이름에 “reflected”가 들어가 있어도 DB를 경유한 SonarQube Stored XSS 케이스에서도 동일하게 잡힌다.

차이는 영향 범위다. 35번 reflected 케이스는 공격자 본인의 브라우저에서 즉시 실행됐다. 이번 SonarQube Stored XSS 케이스는 댓글 하나만 DB에 저장되면 그 게시글을 보는 모든 사용자 화면에서 XSS가 실행된다. 한 번의 입력이 N명에게 영향을 준다는 게 본질적인 차이다. 그래서 같은 S5131이어도 stored는 우선순위를 더 높게 잡아야 한다.

SonarQube Stored XSS — S5131 룰이 DB 출력 단에서 잡힌 화면

레거시에서 발견된 패턴

댓글 시스템 리뉴얼 중 SonarQube 스캔을 돌렸더니 S5131이 18건 나왔다. 분류하니 세 가지 케이스였다.

패턴 1. JSP scriptlet에서 DB 조회값 직접 출력

가장 많이 나온 형태. 댓글 목록을 루프 돌면서 내용을 그냥 뽑아냈다.

<%
List<Comment> comments = commentService.findByPostId(postId);
for (Comment c : comments) {
%>
<div class="comment-item">
  <span class="author"><%= c.getAuthorName() %></span>
  <p class="content"><%= c.getContent() %></p>  <!-- S5131 여기서 잡힘 -->
</div>
<%
}
%>

c.getContent()가 DB에서 온 값이다. 사용자가 댓글 저장 시 <script>document.cookie='x='+document.cookie</script> 같은 걸 넣어뒀으면 이 페이지를 여는 모든 사용자 쿠키가 날아간다. SonarQube는 commentService.findByPostId()로 가져온 값이 결국 외부 입력(사용자가 저장한 데이터)임을 taint analysis로 추적한다.

패턴 2. 사용자 프로필 description 출력

// UserProfileController.java
@GetMapping("/profile/{userId}")
public String viewProfile(@PathVariable Long userId, Model model) {
    UserProfile profile = userProfileService.findById(userId);
    model.addAttribute("profile", profile);
    return "profile/view";
}
<!-- profile/view.jsp -->
<div class="profile-bio">
  ${profile.description}  <!-- EL unescaped, S5131 잡힘 -->
</div>

JSP EL 표현식 ${profile.description}은 기본적으로 이스케이프를 하지 않는다. JSTL c:out 없이 그냥 ${}로 쓰면 그 자체가 취약 패턴이다. 내 프로필 소개란에 XSS 페이로드를 저장해두면 내 프로필을 보는 사람 전체가 영향받는다.

패턴 3. Spring @ResponseBody로 HTML 직접 응답

// CommentApiController.java
@GetMapping("/api/comments/{postId}")
@ResponseBody
public String getComments(@PathVariable Long postId) {
    List<Comment> comments = commentService.findByPostId(postId);
    StringBuilder sb = new StringBuilder("<ul>");
    for (Comment c : comments) {
        sb.append("<li>").append(c.getContent()).append("</li>");  // S5131
    }
    sb.append("</ul>");
    return sb.toString();
}

REST API 응답으로 HTML 문자열을 직접 조립해서 반환하는 케이스다. 프론트에서 innerHTML로 그냥 박아 넣으면 XSS 완성이다. 서버 사이드 SonarQube Stored XSS와 브라우저 DOM XSS가 겹치는 지점이다.

시도 1. 입력 시 sanitize — 실패

35번 reflected 작업을 하고 나서 생긴 버릇인지, 이번에도 먼저 “입력 저장 시점에 막으면 어떨까”를 시도했다. 댓글 저장 서비스에 sanitize 로직을 추가했다.

// CommentService.java — 저장 시점 sanitize 시도
public Comment saveComment(CommentDto dto) {
    String sanitized = dto.getContent()
        .replaceAll("(?i)<script.*?>.*?</script>", "")
        .replaceAll("on\\w+\\s*=", "")  // onerror=, onclick= 등
        .replaceAll("javascript:", "");

    Comment comment = new Comment();
    comment.setContent(sanitized);
    comment.setAuthorId(dto.getAuthorId());
    return commentRepository.save(comment);
}

예상대로 S5131은 그대로였다. 입력 시점에 sanitize를 하든 안 하든 SonarQube는 출력 단을 본다. DB에서 꺼낸 값이 인코더 없이 HTML로 나가는 데이터 플로우가 여전히 존재하기 때문에 룰은 사라지지 않는다.

35번에서도 배운 거지만 stored 케이스에서 다시 확인한 셈이다 — SonarQube는 출력 시점에 알려진 인코더가 끼어있는지를 본다. 입력 시 정제는 인정하지 않는다. 블랙리스트 방식이 본질적으로 불완전하다는 것도 이유지만, 룰 통과 조건 자체가 “출력 직전 인코더”이기 때문이다.

30분 날리고 원점으로 돌아왔다.

시도 2. JSTL c:out 적용 — 부분 통과

35번에서 c:out이 JSP reflected 케이스 대부분을 잡아줬던 패턴이다. stored 케이스에도 동일하게 적용해봤다.

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<%-- 댓글 목록 -->
<c:forEach var="comment" items="${comments}">
<div class="comment-item">
  <span class="author"><c:out value="${comment.authorName}"/></span>
  <p class="content"><c:out value="${comment.content}"/></p>
</div>
</c:forEach>

댓글 목록 JSP 쪽은 대부분 사라졌다. c:outescapeXml="true" 기본값이 SonarQube의 신뢰 목록에 들어 있으니 이 패턴은 통과한다.

그런데 두 케이스가 남았다.

  • 프로필 description — 사용자가 줄바꿈을 <br>로 표현하거나 굵은 글씨를 위해 <b> 태그를 허용해달라고 요구하고 있었다. c:out은 모든 HTML을 escape해버리니 의도한 태그까지 텍스트로 나왔다.
  • Spring @ResponseBody HTML 응답 — Servlet/Controller 코드는 c:out을 쓸 수가 없다. JSP 태그 라이브러리가 적용되는 영역이 아니기 때문이다.

35번이랑 비슷한 상황이지만 stored 컨텍스트에서는 “HTML 일부 허용” 요건이 추가로 붙는다는 게 달랐다. reflected는 대부분 검색어나 오류 메시지 출력이라 HTML 허용 요건이 없었지만, 댓글이나 프로필은 기획 단에서 일부 마크업을 허용하고 싶어하는 경우가 많다. 이 지점에서 단순 escape로는 안 된다는 게 명확해졌다.

시도 3. 출력 시 OWASP Encoder + Markdown 허용 시 sanitize-html

케이스를 세 가지로 나눠서 접근했다.

케이스 A. 단순 텍스트 출력 — OWASP Java Encoder forHtml

댓글 작성자명이나 태그, 제목처럼 HTML 마크업이 불필요한 필드다. 가장 단순한 해법으로 Encode.forHtml()을 쓴다.

<!-- pom.xml -->
<dependency>
    <groupId>org.owasp.encoder</groupId>
    <artifactId>encoder</artifactId>
    <version>1.3.1</version>
</dependency>
<%-- JSP — OWASP Encoder EL 함수 (Jakarta 환경은 owasp.encoder.jakarta) -->
<%@ taglib prefix="e" uri="https://www.owasp.org/index.php/OWASP_Java_Encoder_Project" %>

<c:forEach var="comment" items="${comments}">
<div class="comment-item">
  <span class="author">${e:forHtml(comment.authorName)}</span>
  <p class="content">${e:forHtml(comment.content)}</p>
</div>
</c:forEach>

S5131 사라진다. SonarQube는 Encode.forHtml()을 알려진 sanitizer로 인식한다.

케이스 B. Spring @ResponseBody HTML 조립 — Encode 정적 메서드

import org.owasp.encoder.Encode;

@GetMapping("/api/comments/{postId}")
@ResponseBody
public String getComments(@PathVariable Long postId) {
    List<Comment> comments = commentService.findByPostId(postId);
    StringBuilder sb = new StringBuilder("<ul>");
    for (Comment c : comments) {
        sb.append("<li>")
          .append(Encode.forHtml(c.getContent()))  // S5131 해소
          .append("</li>");
    }
    sb.append("</ul>");
    return sb.toString();
}

이 패턴은 응급 처치에 가깝다. 장기적으로는 API가 JSON을 반환하고 프론트에서 렌더링하는 구조로 분리하는 게 맞다. 하지만 레거시에서 당장 SonarQube Stored XSS를 잡아야 하는 상황이라면 이게 가장 빠른 경로다.

케이스 C. HTML 일부 허용 — jsoup whitelist

프로필 description처럼 <b>, <em>, <br> 정도를 허용해야 하는 경우다. 단순 인코딩을 하면 사용자가 입력한 태그가 텍스트로 보이고, 아무 처리 없이 출력하면 XSS가 된다. 이때 whitelist 기반 sanitizer가 답이다.

<!-- pom.xml -->
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.17.2</version>
</dependency>
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;

// 출력 직전에 whitelist 기반 정제
public String sanitizeAllowedHtml(String input) {
    if (input == null) return "";
    // 허용할 태그·속성만 명시 (나머지는 전부 제거)
    Safelist safelist = Safelist.none()
        .addTags("b", "i", "em", "strong", "br", "p")
        .addAttributes("a", "href");
    return Jsoup.clean(input, safelist);
}
// UserProfileController.java
@GetMapping("/profile/{userId}")
public String viewProfile(@PathVariable Long userId, Model model) {
    UserProfile profile = userProfileService.findById(userId);
    // 출력 직전에 sanitize — 뷰로 넘기기 전 한 번만
    String safeDescription = sanitizeAllowedHtml(profile.getDescription());
    model.addAttribute("safeDescription", safeDescription);
    return "profile/view";
}
<!-- profile/view.jsp — 이미 sanitize된 값이므로 unescape 출력 -->
<div class="profile-bio">
  ${safeDescription}
</div>

jsoup whitelist는 SonarQube가 직접 인식하는 sanitizer는 아니다. 그래서 이 경우에는 sanitize된 값을 별도 변수로 분리해서 넘기는 방식으로 데이터 플로우 자체를 끊어야 한다. safeDescription이 이미 정제된 값임을 모델에서 명확히 분리하면 taint analysis 추적에서 “새 값”으로 처리된다.

최종 해결 — 컨텍스트별 출력 인코딩 + Markdown은 jsoup whitelist

18건을 케이스별로 정리했다.

// 최종 적용 요약

// ① 단순 텍스트 출력 (작성자명, 제목 등) — OWASP Encoder
${e:forHtml(comment.authorName)}
Encode.forHtml(value)

// ② 속성 컨텍스트 (input value, data-* 등)
${e:forHtmlAttribute(comment.authorName)}
Encode.forHtmlAttribute(value)

// ③ HTML 일부 허용 (프로필, 게시글 본문) — jsoup Safelist
Jsoup.clean(input, Safelist.none().addTags("b","em","br","p"))

// ④ Spring Controller HTML 응답 (임시 조치)
Encode.forHtml(c.getContent())

핵심 3가지로 정리하면:

  1. 출력 시점에 처리한다 — 저장 시 sanitize는 SonarQube 통과 조건이 아니다. 출력 직전에 인코딩해야 한다.
  2. 컨텍스트를 구분한다 — HTML body에 나올 땐 forHtml, 속성에 나올 땐 forHtmlAttribute, JS 컨텍스트에 들어갈 땐 forJavaScript. 35번에서 다뤘던 것과 동일한 원칙이다.
  3. HTML 허용이 필요하면 whitelist로 — 블랙리스트(특정 태그 제거)는 우회 벡터가 너무 많다. jsoup Safelist처럼 허용 목록을 명시하는 방식만 안전하다.

stored와 reflected의 근본 차이 하나만 다시 짚으면 — reflected는 그 요청을 보낸 사람만 피해를 본다. SonarQube Stored XSS는 DB에 딱 한 번 저장되는 것으로 그 데이터를 조회하는 모든 사람이 영향을 받는다. XSS로 세션 쿠키를 탈취하는 공격 연계를 생각하면 stored가 훨씬 위험하다 — 공격자가 한 번 심어두면 자동으로 퍼진다.

정리

SonarQube Stored XSS 룰의 본질은 35번 reflected와 동일하다. 외부 입력(이번엔 DB 경유)이 인코딩 없이 HTML 출력으로 흘러가는 데이터 플로우 — 이게 S5131이 잡는 것 전부다. Sonar는 reflected와 stored를 구분하지 않는다. 같은 룰, 같은 해법(출력 시점 인코딩), 다른 시나리오다.

35번 reflected 케이스와 비교하면 시나리오만 다르다. 반사 경로가 “요청 → 응답”이 아니라 “입력 → DB → 다른 사용자 응답”으로 늘어진다. 그리고 HTML 일부 허용 요건이 붙는 경우가 많다는 게 실전에서 느낀 차이다. jsoup whitelist를 추가로 다뤄야 했던 이유다.

다음 편은 브라우저 JS 단에서 잡히는 DOM XSS(S5696)다. innerHTML 동적 갱신이 주 패턴이고, DOMPurify 적용까지 4가지 시도를 정리할 예정이다.

이 시리즈 이전 편들 — SonarQube Path Traversal(S2083), SonarQube XXE(S2755) — 도 같은 금융권 SI 컨텍스트에서 작업한 기록이다.

룰 본문은 SonarSource 공식 룰 페이지(S5131)에서 확인할 수 있다.