Sanitizer API로 안전하게 DOM 조작하기


원문: Jack J, https://web.dev/sanitizer/

라이선스: CC BY 4.0

Thumbnail Unsplash에 있는 Towfiqu barbhuiya의 사진

새로운 Sanitizer API는 페이지에 임의의 문자열을 안전하게 삽입할 수 있는 강력한 처리방법을 구축하는 것을 목표로 한다.

애플리케이션은 항상 신뢰할 수 없는 문자열을 처리하지만, 그 내용을 HTML 문서의 일부로 안전하게 렌더링 하는 것은 어려울 수 있다. 충분히 주의를 기울이지 않으면 실수로 악의를 가진 공격자가 악용할 수 있는 교차 사이트 스크립팅 (XSS)의 기회를 만들기 쉽다.

이런 위험을 완화하기 위해 새롭게 제안된 Sanitizer API는 임의의 문자열이 페이지에 안전하게 삽입될 수 있도록 강력한 처리방법을 구축하는 것을 목표로 한다. 이 글에서는 API를 소개하고 사용법을 설명한다.

// 안전하게 확장!!
$div.setHTML(`<em>hello world</em><img src="" onerror=alert(0)>`, new Sanitizer())

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

사용자 입력, 쿼리 문자열, 쿠키 컨텐츠 등을 DOM에 삽입할 때 문자열을 적절히 이스케이프 해야 한다. 전형적인 XSS의 소스에 해당하는 이스케이프 되지 않은 문자열을 .innerHTML로 DOM 조작하려고 할 때 특히 주의해야 한다.

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

위 입력 문자열에서 HTML 특수 문자를 이스케이프 하거나 .textContent를 사용해 확장하면 alert(0)이 실행되지 않는다. 하지만 사용자가 추가한 <em>도 문자열이 되므로 HTML에서 텍스트 데코레이션을 유지하기 위해 이 방법을 사용할 수 없다.

여기서 가장 좋은 것은 이스케이프 하는 것이 아니라 새니타이징하는 것이다.

사용자 입력 새니타이징하기

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

이스케이프는 특수한 HTML 문자를 HTML 엔티티로 바꾸는 것을 말한다.

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

예제

앞선 예제에서 <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로 구문 분석한다. 그리고 유해하다고 판단되는 태그 및 속성은 제거하고 무해한 속성은 유지해야 한다.

제안된 Sanitizer API 스펙은 이런 처리를 브라우저의 표준 API로 제공하는 것을 목표로 한다.


인터넷 익스플로러는 이를 위해 window.toStaticHTML()를 구현했지만 표준화되지는 않았다.


Sanitizer API

Sanitizer API는 다음과 같은 방식으로 사용된다.

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>

setHTML()Element에 정의되어 있다는 점에 주목해 보자. Element의 메서드이기 때문에 구문 분석할 컨텍스트는 자체 설명이 가능하며(이 경우 <div>), 구문 분석은 내부적으로 한 번 수행되고 결과는 DOM으로 직접 확장된다.

DOM으로 직접 확장하지 않으려면 결과를 HTMLElement로 가져올 수도 있다.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitizer = new Sanitizer()
sanitizer.sanitizeFor("div", user_input) // HTMLDivElement <div>

새니타이징한 결과를 문자열로 가져오려면 가져온 HTMLElement.innerHTML을 사용할 수 있다.(파싱할 적절한 컨텍스트를 지정해야 한다.)

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

사용자가 제어하는 DocumentFragment가 이미 있고 문서에서 유해한 부분만 제거하려면 sanitize()를 사용하면 된다.

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

설정으로 커스터마이징하기

Sanitizer API는 기본적으로 스크립트 실행을 유발하는 문자열을 제거하도록 구성된다. 하지만 설정 객체로 새니타이징 프로세스에 고유한 사용자 정의를 추가할 수 있다.

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allowComments: true
};
// 설정으로 사용자 정의된 새니타이징 결과
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>

new Sanitizer({allowElements: [ "b" ]}).sanitizeFor("div", str)
// <div>hello <b>world</b></div>

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

allowAttributesdropAttributes 프로퍼티는 속성 일치 목록, 즉 키가 속성명이고 값이 대상 엘리먼트 목록 또는 * 와일드카드인 객체가 필요하다.

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>

new Sanitizer({allowAttributes: {"style": ["span"]}}).sanitizeFor("div", str)
// <div><span style="color: red">hello</span></div>

new Sanitizer({allowAttributes: {"style": ["p"]}}).sanitizeFor("div", str)
// <div><span>hello</span></div>

new Sanitizer({allowAttributes: {"style": ["*"]}}).sanitizeFor("div", str)
// <div><span style="color: red">hello</span></div>

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는 기본적으로 안전하도록 설계되었다. 즉, 사용자 설정에 관계없이 알려진 XSS 대상을 절대 허용하지 않는다. 예를 들어, 기본 제공 기준 구성을 재정의할 수 없기 때문에 allowElements: ["script"]는 실제로는 <script>를 허용하지 않는다. 사용자 정의의 목적은 애플리케이션에 특별한 요구 사항이 있는 경우 기본 설정을 재정의하는 것이다.


API 노출

DomPurify와 비교

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

DOMPurify는 브라우저에 Sanitizer API가 구현되지 않은 경우 대체 역할을 할 수 있다.

DOMPurify 구현에는 몇 가지 단점이 있다. 문자열이 반환되면 입력 문자열이 DOMPurify 및 .innerHTML에 의해 두 번 구문 분석된다. 이런 이중 구문 분석 처리 시간을 낭비하지만 두 번째 구문 분석 결과가 첫 번째와 다른 경우로 인해 흥미로운 취약점이 발생할 수 있다.


주의: DOMPurify 취약점에 대한 Securitum 연구: 네임스페이스 혼동을 통한 돌연변이 XSS에 대해 자세히 알아보자.


또한 HTML은 구문 분석을 위해 컨텍스트가 필요하다. 예를 들어, <td><table>에서는 의미가 있지만 <div>는 의미가 없다. DOMPurify.sanitize()는 문자열만 파라미터로 받으므로 구문 분석 컨텍스트를 추측해야 했다.


Sanitizer API가 문자열을 구문 분석하고 Sanitizer API 설명서에서 컨텍스트를 결정하는 방법에 대해 자세히 알아보자.


Sanitizer API는 DOMPurify의 접근 방식을 개선하고 이중 구문 분석이 필요하지 않고 구문 분석 컨텍스트를 명확히 하도록 설계되었다.

API 상태 및 브라우저 지원

Sanitizer API는 표준화 과정에서 논의 중이며 Chrome은 구현 중에 있다.

단계 상태
1. 설명서 만들기 완료
2. 스펙 초안 작성 완료
3. 피드백을 수집하고 설계 반복 진행 중
4. 크롬 체험판(Chrome origin trial) 준비 중
5. 지원 시작되지 않음

Mozilla: 이 제안을 프로토타이핑할 가치가 있다고 생각하고 적극적으로 구현하고 있다.

Webkit: Webkit 메일 목록에서 응답을 참고하면 된다.

Sanitizer API를 사용할 수 있게 활성화하는 방법

about://flags나 CLI 옵션을 통해 활성화하기

Chrome

Chrome은 Sanitizer API를 구현하는 중이다. Chrome 93 이상에서는 about://flags/#enable-experimental-web-platform-features 플래그를 활성화해서 동작을 시도할 수 있다. 이전 버전의 Chrome Canary와 Dev 채널에서는 --enable-blink-features=SanitizerAPI를 활성화해서 바로 사용해 볼 수 있다. 플래그를 사용해 Chrome을 실행하는 방법에 대한 지침을 확인해보자.

Firefox

Firefox 또한 실험적 기능으로 Sanitizer API를 구현 했다. 이를 활성화하려면 about:config에서 dom.security.sanitizer.enabled 플래그를 true로 설정하면 된다.

기능 탐지

if (window.Sanitizer) {
  // Sanitizer API 사용 가능
}

피드백

이 API를 사용해 보고 피드백이 있다면 언제든지 환영한다. Sanitizer API GitHub Issues에서 의견을 공유하고 스펙 작성자 및 이 API에 관심 있는 사람들과 논의해보자.

Chrome 구현에서 버그나 예상치 못한 동작을 발견하면 버그를 보고해달라. Blink>SecurityFeature>SanitizerAPI 구성 요소를 선택하고 세부 정보를 공유해서 구현자가 문제를 추적할 수 있다.

데모

Sanitizer API가 작동하는 것을 보려면 Mike WestSanitizer API Playground를 확인하면 된다.

참고

임재언2021.11.24
Back to list