개발 이야기4분 읽기

HTML 이스케이프는 위치마다 다르다

꺾쇠만 바꾸면 되는 것이 아닙니다. 본문·속성·스크립트·URL에서 각각 필요한 처리와 자동 이스케이프가 놓치는 자리를 정리했습니다.

사용자 입력을 화면에 표시할 때 <>를 바꾸면 된다고 알려져 있습니다. 맞는 말이지만 절반입니다. 같은 값이라도 페이지의 어느 위치에 들어가느냐에 따라 필요한 처리가 다릅니다.

다섯 가지 위치

같은 문자열이 들어갈 수 있는 자리는 대략 이렇게 나뉩니다.

<div>여기</div>                    <!-- HTML 본문 -->
<div title="여기">                 <!-- 속성 -->
<script>var x = "여기";</script>   <!-- 스크립트 -->
<a href="여기">                    <!-- URL -->
<div style="color: 여기">          <!-- CSS -->

각각 파서가 다르게 읽으므로 탈출하는 방법도 다릅니다.

HTML 본문

< > & " ' 다섯 개를 엔티티로 바꿉니다.

<img src=x onerror=alert(1)>
→ &lt;img src=x onerror=alert(1)&gt;

태그가 열리지 않으므로 글자로만 남습니다. 대부분의 템플릿 엔진이 기본으로 해 주는 처리입니다.

속성

속성값에서는 따옴표를 반드시 씁니다. 따옴표 없이 값을 넣으면 공백만으로 속성 경계를 벗어날 수 있습니다.

<div title=사용자입력>

입력이 x onmouseover=alert(1)이면 새 속성이 됩니다. <를 쓰지 않았으므로 꺾쇠만 걸러서는 막히지 않습니다.

따옴표로 감싸고 그 따옴표를 이스케이프하면 막힙니다.

' onmouseover='alert(1)
→ &#39; onmouseover=&#39;alert(1)

href, src, on*처럼 값 자체가 동작을 결정하는 속성은 이스케이프만으로 부족합니다. 아래 URL 항목을 함께 봅니다.

스크립트 안

여기서 HTML 이스케이프는 도움이 되지 않습니다. &lt;는 자바스크립트 문자열 안에서 그냥 글자 넷일 뿐입니다.

값을 스크립트에 넣어야 한다면 문자열을 이어 붙이지 말고 JSON으로 직렬화합니다. 다만 그것만으로 끝나지 않습니다. HTML 파서는 <script> 안에서도 종료 태그를 먼저 찾기 때문에, 문자열 안에 </script>가 들어 있으면 그 지점에서 스크립트가 끝납니다.

값에 </script><script>alert(1)</script> 가 들어오면
문자열 리터럴 안이어도 태그가 닫힙니다

대응은 두 가지입니다.

  • JSON 직렬화 후 <\u003c로 바꿉니다. 자바스크립트 문자열로는 같은 값이고 HTML 파서에는 걸리지 않습니다
  • 더 확실한 방법은 스크립트에 값을 넣지 않는 것입니다. <script type="application/json">에 데이터를 두거나 data- 속성에 넣고 자바스크립트에서 읽습니다

URL

hrefsrc에는 이스케이프 이전에 스킴 검사가 필요합니다.

javascript:alert(1)

이 값에는 꺾쇠도 따옴표도 없으므로 어떤 이스케이프도 걸리지 않습니다. 그대로 링크가 되고 클릭하면 실행됩니다. data:vbscript:도 같습니다.

  • 허용할 스킴을 정해 두고 그 외에는 거부합니다. 보통 http, https, mailto 정도입니다
  • 상대 경로만 받는다면 /로 시작하는지 확인합니다. //example.com은 상대 경로가 아니라 다른 호스트로 가는 주소이므로 함께 걸러야 합니다
  • 쿼리스트링에 값을 넣을 때는 encodeURIComponent를 씁니다. HTML 이스케이프와는 다른 처리입니다

자동 이스케이프가 놓치는 자리

React, Vue, 템플릿 엔진 대부분이 본문 출력은 자동으로 이스케이프합니다. 그래서 실제 사고는 그것을 끄는 문법에서 납니다.

  • React — dangerouslySetInnerHTML
  • Vue — v-html
  • Angular — bypassSecurityTrustHtml
  • 템플릿 엔진 — {{{ }}}, | safe, | raw

이 문법을 쓴 자리가 있다면 그 값이 어디서 왔는지 확인합니다. 사용자 입력이 섞일 수 있다면 DOMPurify 같은 정화 라이브러리를 거칩니다. 직접 만든 태그 목록으로 거르는 방식은 우회 방법이 계속 나오므로 권하지 않습니다.

DOM을 직접 다룰 때도 같습니다. innerHTML 대신 textContent를 쓰면 애초에 파싱되지 않습니다.

CSP는 마지막 방어선

콘텐츠 보안 정책은 인라인 스크립트 실행과 외부 스크립트 출처를 제한합니다. 이스케이프가 뚫렸을 때 피해를 줄여 주지만, 이스케이프를 대신하지는 않습니다. 순서는 이스케이프가 먼저입니다.

확인 순서

  1. 값이 들어가는 위치가 본문인지 속성인지 스크립트인지 확인합니다
  2. 속성이라면 따옴표로 감쌌는지 봅니다
  3. href, src라면 스킴을 검사합니다
  4. 자동 이스케이프를 끄는 문법을 검색합니다. 프로젝트 전체에서 몇 군데 없을 것이고, 그 자리가 점검 대상입니다
  5. 이스케이프된 결과를 확인할 때는 브라우저에 표시된 글자가 아니라 응답 원문을 봅니다. 화면에서는 <&lt;가 똑같이 보입니다

tools.onuel.devHTML 인코딩에 값을 넣으면 어떤 문자가 엔티티로 바뀌는지 확인할 수 있고, HTML 디코딩으로 응답에 남은 값이 이미 처리된 것인지 볼 수 있습니다. 쿼리스트링에 넣을 값은 URL 인코딩에서 따로 확인합니다.

  • #XSS
  • #이스케이프
  • #웹보안