바닐라 자바스크립트로 팀 프로젝트를 진행하며, 리펙토링 중에 XSS 공격에 대한 취약점을 보완할 필요성을 느끼고 여러 방법을 탐색하고 적용한 과정을 남겨본다.
교차 사이트 스크립팅(XSS)은 공격자가 웹 사이트에 악성 클라이언트 측 코드를 주입할 수 있는 보안 공격입니다. 이 코드는 피해자에 의해 실행되며 공격자가 접근 제어를 우회하고 사용자를 가장할 수 있습니다.
mdn
쉽게 말해서 공격자가 코드 취약점이 있는 부분에 script
태그 등으로 악성 코드를 주입할 수 있다는 것이다. 그래서 우리 프로젝트에도 악성스크립트(?)를 집어넣어 보았다.
textarea
에 html
코드를 적고 제출해보니 작성한 것을 text
로 인식하는 것이 아니라 html
로 인식해서 해당 html
이 화면에 반영되는 것을 볼 수 있었다.
위와 같은 문제가 일어나는 원인은 코드상에 있는 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를 방지하는 여러가지 해결책을 탐색해보았다.
먼저, toast UI와 web 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가 아직 모든 브라우저에서 지원을 하지 않아서 사용하기에는 어려움이 있었다.
가장 잘 알려진 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된 문자열을 innerHTML
로 DOM
에 삽입해 줘야한다는 점인데, 이는 DOMPurify 및 .innerHTML에 의해 두 번 파싱되는 결과를 낳는다. 이를 개선한 것이 sanitizeAPI라고 한다.
이 정도 기능이면 프로젝트에 적용하는 것에는 무리가 없었지만, 어떤 방식으로 안전한 문자열을 만드는지를 모르고 지나칠 것 같아 또 다른 방법을 찾아보았다.
React에서는 XSS를 어떤 방식으로 방지하는지를 알아보니 문자열 escape를 통해 JSX에 사용자 입력을 안전하게 삽입한다는 것을 알게 되었다.
즉 <,>,&,',"등을 &와 같은 HTML Entity로 이스케이프해서 렌더링 하기 때문에 입력된 문자를 HTML 마크업으로 인식하지 않고 일반 문자로 인식하도록 할 수 있다.
React.js의 github을 보면 escape처리를 어떻게 했는지 알 수 있었다.
그렇다면 escape와 sanitize의 차이는 뭘까?
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 = """;
break;
case 38: // &
escape = "&";
break;
case 39: // '
escape = "'"; // modified from escape-html; used to be '''
break;
case 60: // <
escape = "<";
break;
case 62: // >
escape = ">";
break;
default:
continue;
}
export const OnlyTextCardView = (text) => {
const sanitizedText = escapeTextForBrowser(text);
return `
<div class="card__content--only-text">${sanitizedText}</div>
`;
};
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
꼼꼼하시네요! 글 잘보고가요 :)