August 14, 2026
Stored XSS 대응, 라이브러리를 고른 이유 — OWASP Java HTML Sanitizer 도입기
admin이 HTML을 이용해 자유롭게 페이지를 꾸밀 수 있는 기능이 있다. 이 기능은 “무검증 HTML 저장, 렌더링”이라는 전형적인 Stored XSS 보안 문제를 내포하고 있었다.

By Evan Song
14 min read
이번 글은 단순히 XSS 대응방안 뿐만 아니라, "자유롭게 꾸미게 해달라"는 기획 요구사항과 보안이 충돌할 때 무엇을 근거로 어디까지 허용할지를 결정하는데 초점을 둔다.
판단의 잣대가 필요해서 Saltzer와 Schroeder의 "The Protection of Information in Computer Systems(1975)"를 참고했다. 보안 설계 원칙 여덟 가지를 정리한 논문인데, 그 중 한 가지 원칙을 이번 작업에서 실제로 적용했다.
- fail-safe defaults: 화이트리스트를 택한 근거가 됐다.
1. 위협 모델은 무엇인가?
1.1 위협모델
처음엔 등록 주체는 내부 운영자(admin)이라 신뢰할 수 있지 않은가? 라는 생각이 들었다.
하지만 admin 계정이 탈취당한다면 실제 악용 가능성이 있다고 생각했다.
개발자는 사람이 아니라 시스템을 신뢰해야한다. 따라서 운영자를 신뢰할 수 있느냐와 무관하게, 저장소를 거쳐 방문자의 브라우저에서 실행될 데이터라면 시스템이 검사해야 한다.
2. 실사용 데이터 전수 조사
보안 상 문제가 되는 부분은 JS 실행이였다. 실사용 데이터에서는 보안 위험 사항은 없었다.
막연히 어떤 태그들을 막아야하는지 고민하는 대신, DB에 저장되어있는 데이터를 검색해보았다.
<script>: 0건on*이벤트 핸들러: 0건javascript:URL: 0건<form>,<iframe>,<object>,<base>: 0건
실제로 쓰이는 건 div, style, span, p, h1, br, img 같은 레이아웃, 텍스트, 이미지 태그였고, 속성은 class가 가장 많았다. 저장 형식은 모두 <!DOCTYPE html>로 시작하는 완전한 문서였다.
이 데이터 조사를 통해, 레이아웃, CSS, 이미지, 강조를 전부 허용하고 JS 실행만 제거하면 Stored XSS를 예방할 수 있다고 판단했다. 일종의 가드레일을 두는 셈이었다.
그렇다면 이 가드레일은 어떻게 구현할까?
3. 해결책 탐색
구현에 들어가기 전에 기준부터 세웠다.
- fail-safe defaults: 접근 결정을 배제가 아니라 허가에 기반하라.
즉, 허용할 것의 목록을 만들고, 목록에 없는 것은 전부 제거하라는 뜻이다.
이는 화이트 리스트로 접근을 허용하라는 말로 풀이 될 수 있었다. 따라서 블랙리스트로 구현한다면 어떤 부분에서 문제가 생길 수 있는지 확인해보았다.
3.1 블랙리스트가 취약한 네 가지
정규식 필터링은 같은 의미의 표기 변형에 취약하다.
<img src=x OnErRoR=alert(1)> <!-- 대소문자 -->
<img src=x/**/onerror=alert(1)> <!-- 주석 삽입 -->
<img src=x onerror=alert(1)> <!-- 엔티티 인코딩 --><img src=x OnErRoR=alert(1)> <!-- 대소문자 -->
<img src=x/**/onerror=alert(1)> <!-- 주석 삽입 -->
<img src=x onerror=alert(1)> <!-- 엔티티 인코딩 -->onerror라는 문자열을 찾아 지우는 코드는 이 셋을 다르게 취급하지만, 브라우저는 셋 다 동일하게 실행한다. 변형은 무한히 만들 수 있고, 블랙리스트는 그 무한을 따라잡을 수 없다.
위처럼 변형 목록을 늘리는 것으로 해결되지 않는 문제가 더 있었다.
첫째, 불안정한 토크나이저를 만들게 된다.
<img/src=x/onerror=alert(1)> <img src=x onerror=alert(1)
케이스를 하나씩 추가하다 보면 결국 토크나이저를 만들게 된다. 명세가 아니라 내가 떠올린 케이스만큼만 만든 토크나이저다. 빠뜨린 케이스가 곧 우회 경로가 된다.
**둘째, 문자열 삭제는 재조합 문제를 만든다.**
```xml
<scr<script>ipt>alert(1)</script>
케이스를 하나씩 추가하다 보면 결국 토크나이저를 만들게 된다. 명세가 아니라 내가 떠올린 케이스만큼만 만든 토크나이저다. 빠뜨린 케이스가 곧 우회 경로가 된다.
**둘째, 문자열 삭제는 재조합 문제를 만든다.**
```xml
<scr<script>ipt>alert(1)</script>위와 같이 공격해올 경우 <script>를 찾아 지우면
셋째, 속성 경계 판정이 어렵다.
예를들어img를 허용하게 되면 onerror 속성이 포함된 태그의 경우 이를 걸러내야한다. 즉, 속성이 어디부터 어디까지인지 판단해야 한다.
<img src="x" alt="a > b" onerror=alert(1)><img src="x" alt="a > b" onerror=alert(1)>alt 값 안의 >는 태그의 끝이 아니다. 따옴표 안이기 때문이다. > 하나를 어떻게 취급할지가 그 시점의 상태에 달려 있고, 그 상태는 앞의 문자들을 순서대로 읽어야만 알 수 있다.
넷째, 태그를 텍스트로 쓰는 정상 콘텐츠를 구분할 수 없다.
운영자가 안내 문구에 다음과 같이 쓰는 경우
<p>이 필드에는 <script> 태그를 쓸 수 없습니다</p><p>이 필드에는 <script> 태그를 쓸 수 없습니다</p>문자열 방식은 script 를 지울 것이다. 그렇다면 정상 콘텐츠가 깨진다. 태그와 텍스트를 구분하려면 구조가 필요하다.
네 가지 문제의 뿌리는 같다. 전부 위험한 것을 찾아 지우는 방식이라서 생긴다.
그래서 화이트리스트로 확정했다. 앞 장에서 실사용 데이터를 전수 조사한 것이 여기서 기준을 정하는 근거가 되었다. 따라서 "무엇을 막아야 하는가"보다 "무엇을 열어야 하는가"를 기준으로 삼는 것이 무한히 발생할 수 있는 엣지 케이스들을 잡아주는 역할을 한다.
3.2 그런데 파싱은 누가 하는가
화이트리스트로 정하더라도 앞의 네 가지 문제 중 셋은 여전히 남는다.
무엇이 태그인지, 속성 경계가 어디인지, 태그인지 텍스트인지를 판정해야 허용 목록을 적용할 수 있기 때문이다.
즉 선택지는 "파싱을 하느냐 마느냐"가 아니라 브라우저와 같은 규칙으로 파싱하느냐, 내가 임의로 만든 규칙으로 파싱하느냐였다.
그래서 HTML5 파싱 명세를 찾아보았다.
파싱은 문자 스트림을 토크나이저와 트리 구성기라는 두개의 상태 기계로 통과시키는 알고리즘이다. 어려운 쪽은 트리 구성이었다. 명세는 올바른 HTML만 정의하는 것이 아니라, 깨진 입력에 대해 어떤 트리가 나와야 하는지까지 결정론적으로 규정한다.
<b><i>굵고 기울임</b> 기울임만</i>
<!-- 잘못 교차된 태그를 adoption agency algorithm으로 재구성 --><b><i>굵고 기울임</b> 기울임만</i>
<!-- 잘못 교차된 태그를 adoption agency algorithm으로 재구성 -->브라우저들이 깨진 HTML을 똑같이 렌더링 하는 이유가 이것이다. 그리고 파서에 이 복구 규칙을 한 군데라도 다르게 구현하면, 그 차이가 곧 우회 경로가 된다. 서버는 안전하다고 판정했는데 브라우저는 다른 트리를 만드는 상황이 생길 수 있다.
구현 공수도 문제지만 유지보수 측면이 더 컸다. 버그가 있으면 라이브러리는 CVE로 공시되고 보완되지만, 자체 구현은 계속해서 유지보수하지 않으면 계속 모른채로 남게된다는 문제가 있었다.
따라서 라이브러리를 사용하기로 결정했다.
3.3 Sanitizer는 어떻게 동작하는가
라이브러리를 쓰기로 했으면 원리는 알고 써야 한다. 파서 기반 정제기는 4개 부품의 조합이다.
- HTML 파서: 문자열을 HTML5 파싱 알고리즘으로 DOM 트리로 변환
- 정책 테이블: 허용 요소 / 요소별 허용 속성 / 속성값 조건
- 트리 워커: 노드마다 정책 조회 후 제거·언랩·값 검증
- 직렬화기: 정리된 트리를 문자열로 재작성
핵심은 "위험한 문자열을 찾아 지우는 것"이 아니라 파싱해서 구조로 만든 뒤, 구조 단위로 재구성하는 것이다. 파서가 표기 변형을 동일한 구조로 정규화하므로 인코딩 우회가 성립하지 않는다.
그리고 onerror, onclick이 제거되는 이유는 허용 목록에 없어서다.
따라서 HTML 표준에 새 이벤트 핸들러가 추가돼도 정책을 고칠 필요가 없다. fail-safe defaults가 코드로 구현된 형태인 셈이다.
3.4 후보 비교와 선택
판단 기준은 **"보안 정제가 설계 목적인가"**와 "우리 데이터를 감당하는가" 두가지 였다.
위에 따라 OWASP Java HTML Sanitizer를 선택했다. 이유는 아래와 같았다.
- 공인성: OWASP 재단 산하 프로젝트(Google Caja 계보)로 Google AppSec 팀이 유지관리하고,
- 유지보수성: 2026년 3월 릴리스가 확인될 만큼 활발하며,
- 신뢰성: 적대적 보안 검토를 거쳤고 취약점 신고, 공시 절차와 보안 공지 메일링 리스트를 운영하고 있기 때문이다.
4. 라이브러리의 취약점 발견
리서치 중 우리 계획과 직접 충돌하는 취약점을 발견했다.
문제는 CSS를 살리기 위해 반드시 필요한 allowTextIn("style")이 이 취약점의 전제 조건이라는 것이다. <style> 내부 텍스트를 통과시키는 경로의 검증이 얇았다는 뜻이다. 찾아보니 과거에도 SELECT, STYLE, OPTION 요소의 정책 미적용 이슈가 보고된 이력이 있다.
문서의 PoC를 참고해 대응은 세 가지로 정리했다.
- 패치 버전(20260102.1) 이상 사용
noscript를 화이트리스트에서 배제- 통합테스트에 CVE 재현 페이로드를 넣어 차단을 직접 확인
이 CVE를 통해 이 라이브러리는 취약점이 발견되면 신고, 공시, 패치가 작동한다는 것을 확인하였고 덕분에 더욱 신뢰가 갔다.
만약 자체 구현이었다면 같은 버그가 아무도 모르는 채 남아 있었을 것이다.
5. 결정과 구현
트레이드 오프의 기준은 "요구사항(CSS 자유도)을 축소하지 않으면서, 실행 가능한 공격만 닫는다" 였다. 따라서 3가지 트레이드 오프를 진행하였다.
위와 같은 트레이드오프로 코드를 구현하였다.
private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
.allowElements(
"div", "span", "section", "header", "footer", "p",
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "strong", "ul", "ol", "li", "br", "img", "style",
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup", "col")
.allowAttributes("class").globally()
.allowAttributes("src", "alt", "width", "height").onElements("img")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowAttributes("span").onElements("col", "colgroup")
.allowUrlProtocols("https")
.allowTextIn("style")
.allowStyling()
.toFactory();private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
.allowElements(
"div", "span", "section", "header", "footer", "p",
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "strong", "ul", "ol", "li", "br", "img", "style",
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup", "col")
.allowAttributes("class").globally()
.allowAttributes("src", "alt", "width", "height").onElements("img")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowAttributes("span").onElements("col", "colgroup")
.allowUrlProtocols("https")
.allowTextIn("style")
.allowStyling()
.toFactory();허용 목록(allowElements(...) )
실사용 전수조사에서 나온 태그를 전부 포함했다.
허용 속성(allowAttributes("class").globally() )
실사용이 class 기반 스타일링이였다. 태그를 허용해도 속성은 자동으로 따라오지 않으므로 명시가 필요하다.
onerror는 열거하지 않았으므로 제거된다.
허용 URL 프로토콜(allowUrlProtocols("https") )
javascript:, data: 스킴을 원천 차단하면서 https 이미지는 허용.
CSS 텍스트 허용(allowTextIn("style") )
style 요소를 허용해도 내부 CSS 텍스트는 기본적으로 버려진다.
다만 CVE-2025-66021은 noscript+style 허용에 allowTextIn을 적용하면 CSS가 정제되지 않아 정책에 없는 태그가 통과 될 수 있다고 지적했다.
따라서 noscript를 화이트리스트에서 뺐다.
인라인 스타일 검증 허용(allowStyling() )
인라인 style 속성을 내장 CssSchema로 검증해서 통과시킨다.
실제로 통합테스트에서 background:url(javascript:...)가 섞인 인라인 스타일이 안전한 선언만 남기고 정리됐다.
6. 통합테스트
정책 자체의 단위 검증과 별개로, 통합테스트는 "요청 → 저장→ 조회"흐름에 집중했다.
6.1 핵심 케이스: 8종의 공격
실데이터 스타일 문서 하나에 공격 8종을 심어 통째로 넣었다.
심은 공격
<script>블록onclick이벤트 핸들러<img onerror><iframe><svg onload>- 인라인 sytle 속성 안의
url(javascript:) <style>블록 안의expreesion()<a href="javascript:">스크립트
결과
- 실행 가능한 백터는 전부 제거
<style>블록 전체,.pintro구조,class, 이모지 엔티티 등 정상 요소는 전부 보존- 닫히지 않은
<p>태그는sanitizer가 자동으로 닫음 - 정제 전후 렌더링 스크린샷 비교 결과 레이아웃, 컬러, 카드 구조 동일
<!-- Before: 공격이 섞인 입력 (발췌) -->
<script>alert('XSS')</script>
<div class="pintro" onclick="alert('XSS')">
<img src="x" onerror="alert('XSS')" />
<a href="javascript:alert('XSS')">여기 클릭</a>
<!-- After: 저장된 정제본 (발췌) -->
<div class="pintro">
<img src="x" />
여기 클릭<!-- Before: 공격이 섞인 입력 (발췌) -->
<script>alert('XSS')</script>
<div class="pintro" onclick="alert('XSS')">
<img src="x" onerror="alert('XSS')" />
<a href="javascript:alert('XSS')">여기 클릭</a>
<!-- After: 저장된 정제본 (발췌) -->
<div class="pintro">
<img src="x" />
여기 클릭그런데 실측하면서 예측이 틀린 지점이 나왔다.
6.2 경로만 적은 이미지는 살아남는다
allowUrlProtocols("https")를 걸었으니 https로 시작하지 않는 주소는 전부 지워질 거라 예상했다. 실측 결과는 달랐다.
<img src="https://cdn.example.com/a.png"> <!-- 보존 -->
<img src="x"> <!-- 보존 (예상 밖) -->
<img src="http://example.com/a.png"> <!-- 제거 -->
<img src="data:text/html,..."> <!-- 제거 --><img src="https://cdn.example.com/a.png"> <!-- 보존 -->
<img src="x"> <!-- 보존 (예상 밖) -->
<img src="http://example.com/a.png"> <!-- 제거 -->
<img src="data:text/html,..."> <!-- 제거 -->https:, http:, data:처럼 콜론 앞에 붙는 부분을 스킴이라고 하는데, allowUrlProtocols는 스킴이 붙어 있을 때 그것이 허용 목록에 있는지를 검사한다. src="x"처럼 스킴이 아예 없는 상대경로는 검사 대상이 아니라 그냥 통과한다.
정책의 실제 의미는 "https만 허용"이 아니라 **"상대경로와 https는 허용, 다른 스킴은 차단"**이었다. 상대경로는 우리 서버 안의 리소스를 가리키므로 외부로 나가지 않아 문제는 없지만, 정책 문장과 실제 동작이 다르다는 걸 알고 있어야 한다.
6.4 화이트리스트라서 드러난 버그
운영자가 안내 문구에 코드를 넣으면 일부가 사라진다는 사실도 이 과정에서 발견했다.
<p>List<String> 형태로 반환합니다</p>
<!-- 저장 후: <p>List 형태로 반환합니다</p> --><p>List<String> 형태로 반환합니다</p>
<!-- 저장 후: <p>List 형태로 반환합니다</p> --><String>이 태그처럼 생겼기 때문에 파서가 태그로 인식하고, 허용 목록에 없으니 제거한다.
fail-safe defaults 원칙에서도
허가 기반 방식은 실수하면 거부하는 쪽으로 실패해 금방 드러나지만, 배제 기반 방식은 실수하면 통과시키는 쪽으로 실패해 정상 사용 중에는 드러나지 않는다고 짚는다.
즉, 화이트 리스트는 버그가 잡히지만 블랙리스트는 안잡힌다는 뜻이다.
화면에서 글자가 사라지니 바로 눈에 띄었고, 그래서 새로운 fix 티켓이 됐다. 블랙리스트였다면 반대로 놓친 변형 하나가 조용히 통과했을 것이고, 그건 발견하기 어려웠을 것이다.
7. 배포 후 무엇이 달라졌나
- 공격 페이로드가 섞인 입력도 저장 시점에 정제되어, DB 저장 데이터 자체가 안전하다.
- 운영자 행동 변경이 없다. 완전 문서를 붙여넣어도 서버가 알아서 정규화한다.
8. 마치며
이번 작업을 돌아보며 보안의 기준을 생각해볼 수 있었다.
블랙리스트는 공격자의 모든 공격 변형 패턴에 대응해야한다. 이 경우의 수는 무한대이므로 모두 대비할 수 없다.
반대로 화이트리스트는 우리가 필요한 목록만 추리면 되고, 이는 유한하다.
따라서 무엇을 허용할지를 생각하고, 그 외의 것들을 모두 차단하는 방식이 더욱 보안 결정에 있어 효과적이고 효율적인 방법임을 알게 되었다.