배경
🧴
개발 •  • 읽는데 12분 소요

Sanitizer API를 이용해 안전하게 DOM 조작하기

문자열을 이용한 DOM 조작을 보다 안전하게 처리할 수 있게 도와주는 Sanitizer API를 소개합니다.

#Translation
#JavaScript
#Front-End


⚠️ 현재 Sanitizer API는 실험적(Experimental) 기능 입니다. 실제 프로덕션 환경에서 사용하는 것을 권장하지 않으며, 만약 해당 기능이 필요하다면 대체 라이브러리인 DOMPurify 사용을 권장합니다.


이 글은 Google의 web.dev 아티클 『Safe DOM manipulation with the Sanitizer API』를 번역 및 의역한 것임을 밝힙니다.

웹 애플리케이션은 사용자로부터 다양한 문자열을 입력받지만, 그 입력을 항상 믿을 수 있는 건 아닙니다. 사용자의 입력을 그대로 HTML에 표현하는 경우를 생각해봅시다. 만약 여러분이 보안에 충분한 주의를 기울이지 않는다면, 악의를 가진 사용자들로부터 교차 사이트 스크립팅(XSS, cross-site scripting) 공격을 쉽게 받을 수 있겠죠.

이러한 위험을 완화시키기 위해 제안된 Sanitizer(새니타이저, 살균제・소독제라는 뜻) API 는 임의의 문자열이 페이지에 안전하게 삽입될 수 있게 도와주는 방법을 제공합니다. 이번 포스트에서는 Sanitizer API를 소개하고 그 사용법에 대해 알아봅니다.

$div.setHTML(
  // 아래 문자열을 안전하게 만들어봅시다
  `<em>hello world</em><img src="" onerror=alert(0)>`,
  new Sanitizer()
);

사용자 입력을 이스케이프하기

사용자 입력이나 쿼리 스트링, 쿠키 같은 것들을 DOM에 나타내려고 할 때, 이러한 문자열은 반드시 적절하게 이스케이프(escape) 되어야 합니다. 특히 DOM 조작 API 중에서 .innerHTML을 쓸 때에는 더욱 주의를 기울여야 하는데, 해당 API는 기본적으로 문자열을 이스케이프하지 않기 때문에 전형적인 XSS 공격 방법으로 쓰이기 때문입니다.

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

만약 여러분이 문자열에 나타나는 특수문자를 직접 이스케이프하거나 .textContent를 이용한다면, alert(0)은 실행되지 않을 것입니다. 하지만 사용자가 추가한 <em> 태그는 문자열 그대로 인식되기 때문에 <em> 태그가 실제로 렌더될 것입니다. 결국 이 방법도 사용자의 입력을 검증하는 용도로는 적합하지 않을 것 같네요.

따라서, 우리가 이 상황에서 선택할 수 있는 가장 좋은 방법은 바로 문자열을 이스케이프 하기보다는 새니타이징 하는 것입니다.

이스케이프와 새니타이징의 차이점

그렇다면 이스케이프와 새니타이징의 차이점에 대해 궁금하신 분이 계실 것 같습니다.

이스케이프(Escape) 는 HTML 특수문자를 HTML 엔티티로 대체하는 것을 의미합니다. HTML에서 문자열 <, > 를 나타내기 위해서는 &lt;, &gt; 를 쓰는 것이 대표적인 예시일 것 같네요.

반면, 새니타이징(Sanitizing) 은 HTML 문자열에서부터 의미적으로 유해한 부분들을 제거하는 것을 의미합니다. 가령 위에서 언급한 예시에서 onerror 콜백에 유해한 스크립트를 실은 부분을 삭제하는 경우가 있습니다.

예시

이전에 살펴봤던 예시에서, <img onerror> 라는 부분은 에러 핸들러를 자동적으로 호출하게 만듭니다. 하지만 만약 아래처럼 onerror 핸들러 자체가 삭제된다면, <em> 부분에는 영향을 미치지 않으면서 DOM에 안전한 문자열을 넣을 수 있습니다.

// XSS 위험이 도사립니다 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`;

// 새니타이징은 위험 요소를 아예 제거합니다 ⛑
$div.innerHTML = `<em>hello world</em><img src="">`;

이처럼 새니타이징을 위해서는 입력 문자열을 HTML로 구문 분석한 후, 유해한 태그나 속성은 삭제하면서 그렇지 않은 것들은 남겨두어야 합니다.

이러한 기능을 각 브라우저에서 제공하기 위해, 현재 표준 새니타이저 API 사양이 제안된 상태입니다.

아, 참고로 IE에서는 이러한 목적의 API를 window.toStaticHTML() 라고 만들어 둔 게 있습니다. 하지만 뭐 다들 아시다시피… 이게 절대 표준이 될 일은 없었습니다.

Sanitizer API

Sanitizer에는 내부적으로 세 가지 종류의 API를 제공합니다.

Element.setHTML

Element.setHTML(input, sanitizer) 는 HTML 스트링을 파싱 및 새니타이징하고, 그 결과를 현재 엘리먼트에 DOM으로 렌더합니다. 이 API는 Element.innerHTML 의 보다 안전한 버전이며, 사용자로부터 신뢰할 수 없는 입력에 대해서는 반드시 innerHTML 대체제로 사용되어야 합니다.

  • input: 사용자 입력 스트링을 넣습니다.
  • sanitizer: 새니타이저 인스턴스(new Sanitizer())를 넣습니다.

setHTML()은 아래와 같은 방법으로 사용합니다.

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>

Element의 메소드로 setHTML() 이 정의되어 있다는 것에 주목할 필요가 있습니다. 이후에 설명할 내용이지만, 새니타이저는 새니타이징 결과물을 렌더할 컨텍스트의 정보를 명시적으로 전달해주어야 합니다.

하지만 setHTMLElement 의 메소드이기 때문에, 구문 분석할 범위가 Element 내부라는 사실이 자명하다는 것을 쉽게 알 수 있죠(위 예시에서는 <div>에 해당). 그렇기 때문에 렌더할 컨텍스트의 정보를 따로 넘기지 않았음에도 불구하고 그 결과가 Element의 DOM으로 렌더될 것입니다.

Sanitizer.sanitizeFor

새니타이징 결과물을 직접 DOM에 추가하는 방식 대신 HTMLElement 로 결과를 받아오는 방식을 선택하고 싶다면, 두 번째 API인 Sanitizer.sanitizeFor(element, input) 를 사용할 수 있습니다.

이 메소드는 HTML 스트링을 파싱 및 새니타이징하고, 그 결과를 HTMLElement 변수로 리턴합니다. 새니타이징 결과를 즉시 사용하지 않고, JavaScript 변수로 저장하여 나중에 활용하기 위한 용도로 사용할 수 있습니다.

  • element: 파싱할 컨텍스트 역할을 하는, 새니타이징된 결과물을 래핑할 엘리먼트 이름을 넣습니다. 가령 "div", "table" 등을 넣을 수 있겠네요.
  • input: 사용자 입력 스트링을 넣습니다.

아래 예시에서 $userDivdiv 로 래핑된 것을 확인할 수 있습니다.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`;
const sanitizer = new Sanitizer();

// $userDiv는 HTMLDivElement <div> 입니다!
const $userDiv = sanitizer.sanitizeFor('div', user_input);

만약 문자열로 새니타이즈된 결과를 받아오려면, HTMLElement 에서 innerHTML 속성을 사용해 접근할 수 있습니다. 새니타이징 결과를 얻기 위해서는 파싱할 컨텍스트(아래 예시에서 "div")가 여전히 필요하다는 것에 주의하세요.

// <em>hello world</em><img src="">
sanitizer.sanitizeFor('div', user_input).innerHTML;

Sanitizer.sanitize

이전까지의 API가 사용자 입력 스트링을 새니타이징하는 것에 반해, 이미 렌더된 DOM을 새니타이징하기 위해서는 Sanitizer.sanitize(input) 메소드를 사용할 수 있습니다.

만약 여러분이 직접 제어하는 도큐먼트가 있고, 이 중에서 오직 유해한 부분만 새니타이징하고 싶다면 아래처럼 sanitize() 메소드를 쓰는 방식을 고려할 수 있습니다.

$div.replaceChildren(s.sanitize($userDiv));

설정을 통한 커스터마이징

Sanitizer API는 기본적으로 스크립트 실행을 유발하는 문자열을 제거하도록 설정되어 있습니다. 하지만 설정 정보를 담은 객체를 통해, API에 여러분만의 설정을 커스터마이징할 수 있습니다.

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allow,
};

// 커스터마이징한 설정을 적용한 새니타이즈 결과
new Sanitizer(config);

다음 옵션은 삭제 결과가 지정된 요소를 처리하는 방법을 지정합니다.

  • allowElements: 새니타이저의 허용 엘리먼트 목록입니다.
  • blockElements: 새니타이저의 제거 엘리먼트 목록입니다. 자식 엘리먼트에는 영향을 미치지 않습니다.
  • dropElements: 새니타이저의 제거 엘리먼트 목록입니다. 자식 엘리먼트에도 영향을 미칩니다.
const str = `hello <b><i>world</i></b>`;

// 디폴트 새니타이저를 사용했습니다.
new Sanitizer().sanitizeFor('div', str);
// <div>hello <b><i>world</i></b></div>

// `b` 엘리먼트만 허용했기 때문에 `i` 엘리먼트가 새니타이징 되었습니다.
new Sanitizer({ allowElements: ['b'] }).sanitizeFor('div', str);
// <div>hello <b>world</b></div>

// `b` 엘리먼트를 새니타이징했기 때문에 `i` 엘리먼트만 남아있습니다.
new Sanitizer({ blockElements: ['b'] }).sanitizeFor('div', str);
// <div>hello <i>world</i></div>

// 아무 엘리먼트도 허용하지 않았기 때문에 모든 엘리먼트가 새니타이징 되었습니다.
new Sanitizer({ allowElements: [] }).sanitizeFor('div', str);
// <div>hello world</div>

마찬가지로, 다음 옵션을 사용하여 새니타이저의 허용 속성 여부를 제어할 수도 있습니다.

  • allowAttributes
  • dropAttributes

두 속성은 객체를 입력받는데, 객체의 키는 속성 이름, 그리고 값은 타겟 엘리먼트 또는 와일드카드(*)가 될 수 있습니다.

const str = `<span id=foo class=bar style="color: red">hello</span>`;

// 디폴트 새니타이저를 사용했습니다.
new Sanitizer().sanitizeFor('div', str);
// <div><span id="foo" class="bar" style="color: red">hello</span></div>

// `span` 태그에서 `style` 속성만 허용했습니다.
new Sanitizer({ allowAttributes: { style: ['span'] } }).sanitizeFor('div', str);
// <div><span style="color: red">hello</span></div>

// `p` 태그에서 `style` 속성만 허용했습니다.
new Sanitizer({ allowAttributes: { style: ['p'] } }).sanitizeFor('div', str);
// <div><span>hello</span></div>

// 모든 태그(`*`)에서 `style` 속성만 허용했습니다.
new Sanitizer({ allowAttributes: { style: ['*'] } }).sanitizeFor('div', str);
// <div><span style="color: red">hello</span></div>

// `span` 태그에서 `id` 속성을 제거했습니다.
new Sanitizer({ dropAttributes: { id: ['span'] } }).sanitizeFor('div', str);
// <div><span class="bar" style="color: red">hello</span></div>

// 모든 속성을 허용하지 않습니다.
new Sanitizer({ allowAttributes: {} }).sanitizeFor('div', str);
// <div>hello</div>

마지막으로 allowCustomElements 는 커스텀 엘리먼트도 새니타이징 할 것인지 여부에 대한 설정을 지정합니다. 커스텀 엘리먼트 속성을 지정해도, 다른 설정에는 영향을 미치지 않습니다.

const str = `<custom-elem>hello</custom-elem>`;

// 기본적으로 커스텀 엘리먼트를 허용하지 않습니다
new Sanitizer().sanitizeFor('div', str);
// <div></div>

// 허용하는 커스텀 엘리먼트 목록을 명시적으로 지정합니다
new Sanitizer({
  allowCustomElements: true,
  allowElements: ['div', 'custom-elem'],
}).sanitizeFor('div', str);
// <div><custom-elem>hello</custom-elem></div>

Sanitizer API는 안전을 최우선으로 설계되었습니다. 이는 설정 방법에 관계없이 일반적으로 알려진 XXS에 취약한 구성을 허용하지 않는다는 것을 의미합니다.

만약 allowElements: ["script"] 이라는 속성으로 <script> 를 정의하려고 해도 이는 적용되지 않습니다. 왜냐하면 보안에 위배되는 속성은 빌트인된 속성을 오버라이딩할 수 없게 설계되었기 때문입니다. 커스터마이징의 목적은 보안을 위배하는 경우를 만드는 것이 아닌, 애플리케이션에 특별한 요구 사항이 있는 경우 기본 설정을 재정의하는 것입니다.

라이브러리와의 비교

DOMPurify는 동일한 새니타이즈 기능을 제공하는 유명한 라이브러리입니다. Sanitizer API와 DOMPurify의 주요한 차이점은 DOMPurify는 새니타이즈의 결과로 문자열을 반환하기 때문에 .innerHTML 을 이용해 해당 결과값을 DOM 엘리먼트에 다시 덮어써야함을 의미합니다.

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="">`

브라우저에서 Sanitizer API가 지원되지 않는다면, 그 대체제로 DOMPurify를 쓸 수도 있습니다.

다만 DOMPurify의 구현 방식에는 몇 가지 단점이 있습니다. 만약 새니타이즈 결과로 문자열이 반환된다면, 입력된 문자열은 DOMPurify와 .innerHTML 에 의해 두 번 파싱됩니다. 두 번의 파싱을 처리하는데 시간이 낭비되기도 하며, 두 번째 파싱 결과가 첫 번째 파싱 결과와 달라짐으로써 예상하지 못한 추가 취약점이 생길 수도 있습니다.

DOMPurify 취약점에 대한 연구를 직접 확인해보실 수 있습니다: Mutation XSS via namespace confusion.

또한 HTML을 적절하게 새니타이징하기 위해서는 파싱할 컨텍스트가 필요합니다. 예를 들어, <td> 엘리먼트는 <table> 엘리먼트 내에서는 의미가 있지만 <div> 내에서는 의미가 없습니다. DOMPurify.sanitize() 는 오직 한 개의 문자열 인수를 받아 처리하는 함수이기 때문에, 파싱할 컨텍스트를 정확히 알 수 없습니다.

Sanitizer API가 문자열을 파싱하고 컨텍스트를 결정하는 과정을 Sanitizer API 설명에서 확인하실 수 있습니다.

Sanitizer API는 DOMPurify에서의 접근법을 보다 개선시켰고, 두 번 파싱하는 문제와 파싱 컨텍스트를 명확히 하는 문제를 제거하기 위해 설계되었습니다.

브라우저 지원

API 제안 기껏 열심히 설명해놨지만 아직 쓸 수는 없다

글 처음에 언급한 것처럼, Sanitizer API는 현재 실험적 단계입니다. 현재 표준화 논의 중인 단계의 API이며, 현재 크롬과 파이어폭스에서 실험적 기능으로 제공되고 있는 상태입니다.

Sanitizer API 활성화시키기

만약 여러분이 Sanitizer API를 직접 체험해 보고 싶다면, 브라우저에 따라 다른 방법으로 해당 옵션을 활성화할 수 있습니다.

Chrome

크롬은 Sanitizer API를 시범적으로 구현하고 있는 상태입니다. 만약 여러분이 크롬 93 이후 버전을 쓰고 있다면, about://flags/#enable-experimental-web-platform-features 에서 해당 속성을 활성화할 수 있습니다. 이전 버전에서는 --enable-blink-features=SanitizerAPI 플래그를 이용해 가능합니다. 플래그를 이용해 크롬 옵션을 설정하는 방법은 링크를 참조해주세요.

Chrome

파이어폭스에서도 Sanitizer API를 실험적 기능으로 제공하고 있습니다. 이를 활성화하기 위해서는 about:config에서 dom.security.sanitizer.enabled 플래그를 true로 설정하여야 합니다.

활성화 여부 확인

만약 여러분이 Sanitizer API를 활성화했다면, 전역 객체 window 에 속성이 있는지를 확인하는 방법으로 구별이 가능합니다.

if (window.Sanitizer) {
  // Sanitizer API 활성화 상태
}

데모

동작하는 데모를 Sanitizer API 플레이그라운드에서 확인하실 수 있습니다.

API 제안 기껏 열심히 설명해놨지만 아직 쓸 수는 없다

위 사진은 제가 크롬에서 해당 옵션을 활성화하고 직접 데모에서 체험한 사진입니다. 정확히 어떤 부분이 바뀌었는지를 체크해보면, 다음과 같습니다.

  1. <em> 엘리먼트에 달린 onclick 이벤트 핸들러가 삭제되었습니다.
  2. <img 엘리먼트에 달린 onerror 이벤트 핸들러가 삭제되었습니다.
  3. <td> 엘리먼트가 sanitizer.sanitizeFor("table")에 맞게 해석되었습니다. 기존에 없었던 <table>, <tr> 엘리먼트가 새로 생긴 것을 확인하실 수 있습니다.

참고 자료

이 포스트가 유익하셨다면?




프로필 사진

👨‍💻 정종윤

글 쓰는 것을 좋아하는 프론트엔드 개발자입니다. 온라인에서는 재그지그라는 닉네임으로 활동하고 있습니다.


Copyright © 2024, All right reserved.

Built with Gatsby