XSS 방지하기(feat.innerHTML)

김상민·2024년 1월 14일
2

Javascript

목록 보기
2/5
post-thumbnail

🧩 개요

바닐라 자바스크립트로 팀 프로젝트를 진행하며, 리펙토링 중에 XSS 공격에 대한 취약점을 보완할 필요성을 느끼고 여러 방법을 탐색하고 적용한 과정을 남겨본다.

⚔️ XSS란?

교차 사이트 스크립팅(XSS)은 공격자가 웹 사이트에 악성 클라이언트 측 코드를 주입할 수 있는 보안 공격입니다. 이 코드는 피해자에 의해 실행되며 공격자가 접근 제어를 우회하고 사용자를 가장할 수 있습니다.
mdn

쉽게 말해서 공격자가 코드 취약점이 있는 부분에 script 태그 등으로 악성 코드를 주입할 수 있다는 것이다. 그래서 우리 프로젝트에도 악성스크립트(?)를 집어넣어 보았다.


textareahtml 코드를 적고 제출해보니 작성한 것을 text로 인식하는 것이 아니라 html로 인식해서 해당 html이 화면에 반영되는 것을 볼 수 있었다.

👿 innerHTML의 문제

위와 같은 문제가 일어나는 원인은 코드상에 있는 innerHTML 때문이었다. SPA방식으로 개발하다보니 innerHTML로 화면을 갈아끼웠는데 이때 사용자가 입력한 text도 함께 html로 파싱해서 넣기 때문에 이러한 문제가 발생했다.

export const renderWagleList = (cardList) => {
  const wagleList = document.querySelector(".wagle__list");
  wagleList.innerHTML = cardList && cardList.length ? WagleMainView(cardList) : WagleEmptyView();
};

이렇게 card-list안에 innerHTML로 card요소를 넣어주는데,

export const OnlyTextCardView = (text) => {
  return `
<div class="card__content--only-text">${text}</div>
    `;
};

card 요소에는 사용자가 입력한 텍스트를 그대로 받아서 string으로 삽입하는 부분이 있다.
이 때문에 XSS 공격에 그대로 노출되어있다.

🛡 해결책

XSS를 방지하는 여러가지 해결책을 탐색해보았다.

😷 sanitizeAPI

먼저, toast UIweb dev에서 sanitizeAPI에 대해 알게되었다.

const $div = document.querySelector('div')
const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitizer = new Sanitizer()
$div.setHTML(user_input, sanitizer) // <div><em>hello world</em><img src=""></div>

이렇게 sanitizer를 통해 안전한 html로 만든 뒤에 DOM에 삽입하는 방식인데, sanitizeAPI가 아직 모든 브라우저에서 지원을 하지 않아서 사용하기에는 어려움이 있었다.

🧹 DOMPurify

가장 잘 알려진 sanitize 라이브러리인 DOMPurify에 대해서도 알아보았다.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sanitized
// `<em>hello world</em><img src="">`

sanitizeAPI와의 차이점은 결국 sanitized된 문자열을 innerHTMLDOM에 삽입해 줘야한다는 점인데, 이는 DOMPurify 및 .innerHTML에 의해 두 번 파싱되는 결과를 낳는다. 이를 개선한 것이 sanitizeAPI라고 한다.
이 정도 기능이면 프로젝트에 적용하는 것에는 무리가 없었지만, 어떤 방식으로 안전한 문자열을 만드는지를 모르고 지나칠 것 같아 또 다른 방법을 찾아보았다.

🔑 문자열 escape

React에서는 XSS를 어떤 방식으로 방지하는지를 알아보니 문자열 escape를 통해 JSX에 사용자 입력을 안전하게 삽입한다는 것을 알게 되었다.
즉 <,>,&,',"등을 &와 같은 HTML Entity로 이스케이프해서 렌더링 하기 때문에 입력된 문자를 HTML 마크업으로 인식하지 않고 일반 문자로 인식하도록 할 수 있다.

React.js의 github을 보면 escape처리를 어떻게 했는지 알 수 있었다.

⚖️ escape와 sanitize의 차이

그렇다면 escapesanitize의 차이는 뭘까?
escape는 특수한 HTML 문자를 HTML Entity로 바꾸는 것을 말한다.

sanitize는 HTML 문자열에서 스크립트 실행과 같은 유해한 부분을 지우는 것을 말한다.

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`
// 새니타이징 후 ⛑
$div.innerHTML = `<em>hello world</em><img src="">`

올바르게 새니타이징하려면 입력 문자열을 HTML로 구문 분석한다. 그리고 유해하다고 판단되는 태그 및 속성은 제거하고 무해한 속성은 유지해야 한다.
앞선 예제에서 <img onerror>는 에러 핸들러를 실행하게 하지만, onerror 핸들러가 제거되면 <em>은 그대로 두고 DOM에서 안전하게 확장할 수 있다.

😁 코드에 적용

직접 escape 코드를 적어보는 것이 공부에 더 도움이 될 것 같아서 React의 escape방식을 따라 적용해 보았다. escape의 핵심적인 부분은 다음과 같다.

// escapeTextForBrowser.js
 for (index = match.index; index < str.length; index++) {
    switch (str.charCodeAt(index)) {
      case 34: // "
        escape = "&quot;";
        break;
      case 38: // &
        escape = "&amp;";
        break;
      case 39: // '
        escape = "&#x27;"; // modified from escape-html; used to be '&#39'
        break;
      case 60: // <
        escape = "&lt;";
        break;
      case 62: // >
        escape = "&gt;";
        break;
      default:
        continue;
    }
export const OnlyTextCardView = (text) => {
  const sanitizedText = escapeTextForBrowser(text);
  return `
<div class="card__content--only-text">${sanitizedText}</div>
    `;
};

결과

📌 innerHTML 대안

insertAdjacentHTML
textContent
innerText
insertAdjacentText

📌 참조

https://developer.mozilla.org/ko/docs/Glossary/Cross-site_scripting
https://ui.toast.com/weekly-pick/ko_2021124
https://web.dev/articles/sanitizer?hl=ko
https://ko.legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks
https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API

profile
성장하는 웹 프론트엔드 개발자 입니다.

2개의 댓글

comment-user-thumbnail
2024년 1월 22일

꼼꼼하시네요! 글 잘보고가요 :)

1개의 답글