글자는 글자로, 스크립트는 정한 곳에서만
한 줄 요약
사용자가 정한 글자를 HTML 에 넣을 때는 템플릿 엔진의 자동 이스케이프에 맡기고, 그것을 끄는 출구(|safe)를 쓰지 않습니다. 그리고 모든 HTML 응답에 CSP 헤더를 붙여, 어딘가에서 이스케이프가 새더라도 브라우저가 스크립트를 실행하지 않게 둘째 방어선을 둡니다.
왜 이 요구가 필요한가
댓글 본문에 <i>기울임</i> 이라고 썼을 때 화면에 기울어진 글자가 나온다면, 서버가 사용자의 글자를 HTML 문법으로 해석해 내보내고 있다는 뜻입니다. 기울임은 무해해 보이지만, 같은 길로 스크립트 태그나 이벤트 속성도 들어옵니다. 그 스크립트는 우리 사이트의 이름으로 다른 사용자의 브라우저에서 실행됩니다. 이 결함이 XSS(Cross-Site Scripting)입니다.
해법은 오래전에 정리돼 있습니다. 출력하는 자리에서 <, >, &, ", ' 를 문자 참조로 바꾸면 그 글자는 문법이 아니라 글자로 그려집니다. 문제는 이 일을 사람이 자리마다 기억해야 한다면 반드시 한 곳을 빠뜨린다는 점입니다. 그래서 템플릿 엔진의 자동 이스케이프를 켭니다. OWASP 의 XSS Prevention Cheat Sheet 도 대부분의 프레임워크에 자동 인코딩이 들어 있다고 적고, 그것을 우회하는 '탈출구' 를 조심하라고 경고합니다.
어떻게 지키나
jinja2 는 기본값이 자동 이스케이프 꺼짐입니다. 환경을 만들 때 켭니다.
env = Environment(loader=DictLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
env.filters["nl2br"] = nl2br
def nl2br(text):
# 줄마다 먼저 이스케이프하고, 그 사이에 <br> 만 안전한 문자열로 끼운다
return Markup("<br>").join(escape(line) for line in str(text).split("\n"))
함정이 하나 있습니다. 줄바꿈을 <br> 로 보여 주려고 {{ body|replace("\n", "<br>")|safe }} 처럼 쓴 템플릿은, 자동 이스케이프를 켜도 그 자리만 날것으로 나갑니다. |safe 가 '이 문자열은 이미 안전하다' 는 선언이기 때문입니다. 순서를 바꿔 먼저 이스케이프하고 <br> 만 Markup 으로 끼우면 줄바꿈은 살고 태그는 죽습니다.
속성 값도 같은 이스케이프로 지켜집니다. title="{{ name }}" 에서 이름에 큰따옴표가 있어도 " 로 바뀌어 속성이 끊기지 않습니다. 시험은 문자열 비교보다 HTML 파서로 읽는 편이 정확합니다. 태그가 몇 개 생겼는지, 속성이 무엇인지, 글자가 원래대로 보이는지를 확인합니다.
CSP(Content Security Policy)는 응답 헤더로 브라우저에게 '이 페이지는 이 출처의 스크립트만 실행하라' 고 알립니다. script-src 'self' 에 'unsafe-inline' 이 없으면 HTML 안에 끼어든 인라인 스크립트는 실행되지 않습니다. 대신 우리 페이지에도 인라인 스크립트가 없어야 하므로, 페이지가 넘겨야 할 값은 data- 속성에 두고 외부 스크립트가 읽습니다. CSP 는 모든 HTML 응답에 붙여야 의미가 있습니다. 화면마다 붙이면 새 화면에서 빠지므로, 모든 응답이 지나는 한곳(미들웨어)에서 붙입니다.
현장에서 만나는 모습
가장 흔한 회귀는 '여기만 HTML 을 허용해 달라' 는 요청에서 시작합니다. 작성자 이름을 굵게, 공지 본문에 링크를 넣고 싶다는 요구에 |safe 가 하나 붙고, 그 자리가 몇 달 뒤 다른 데이터를 받게 됩니다. HTML 을 정말 허용해야 한다면 이스케이프를 끄는 것이 아니라 검증된 HTML 정리기(sanitizer)를 써야 하고, 그것은 이 모듈의 범위 밖입니다.
CSP 를 처음 켜면 기존 인라인 스크립트가 깨집니다. 그래서 팀들이 'unsafe-inline' 을 넣고 넘어가는데, 그러면 CSP 의 가장 중요한 효과가 사라집니다. 위반 보고(report-uri)를 받아 감사 로그에 남기면 어떤 페이지의 무엇이 막혔는지 보며 고쳐 나갈 수 있습니다. 보고에 담긴 주소의 쿼리에는 토큰이 실려 오곤 하므로, 경로와 호스트만 남깁니다.
English corner
- 「output encoding」 출력하는 자리에서 문맥(HTML 본문, 속성, URL, 자바스크립트)에 맞게 글자를 바꾸는 일입니다. 입력을 걸러 내는 'input validation' 과 짝을 이루지만 대신하지는 못합니다.
- 「escape hatch」 프레임워크의 자동 보호를 끄는 출구입니다. jinja2 의
|safe, React 의dangerouslySetInnerHTML이 대표적입니다. 리뷰에서 "Why do we need the escape hatch here?" 라고 묻습니다. - 「defense in depth」 OWASP 는 CSP 를 이렇게 부르며 첫째 방어선이 되어서는 안 된다고 적습니다. 이스케이프가 먼저, CSP 는 그다음입니다.
다음 실습에서 할 것
게시판 화면이 자동 이스케이프 없이 글과 댓글을 그리고, 본문은 |safe 로 줄바꿈을 넣고, 인라인 스크립트가 있으며 CSP 가 없습니다. 요구 A(사용자 글자는 글자로만)와 요구 B(모든 HTML 응답에 CSP·nosniff, 인라인 스크립트 없음)를 시험으로 쓰고 고친 뒤, CSP 위반 보고를 감사 로그에 남깁니다.