마크다운 렌더러의 XSS 방어 원리와 CommonMark 표준화 배경
마크다운은 처음부터 "일반 텍스트 안에 HTML을 섞어 쓸 수 있다"는 것을 설계 원칙으로 삼은 언어입니다. 이 원칙 때문에 마크다운 렌더러를 직접 만들거나 신뢰할 수 없는 사용자 입력을 마크다운으로 렌더링하는 서비스를 운영한다면, XSS(크로스사이트 스크립팅) 공격 표면을 처음부터 안고 시작하는 셈입니다. 이 가이드는 왜 그런 구조가 됐는지, 실제 어떤 입력이 위험한지, 그리고 이를 어떻게 방어하는지 정리합니다.
1. 마크다운은 원래 HTML을 그대로 통과시키도록 설계됐다
2004년 존 그루버(John Gruber)가 처음 만든 Markdown.pl의 공식 문법 설명에는 "블록 레벨 HTML 태그는 변환 없이 그대로 통과한다"는 규칙이 명시돼 있습니다. 즉 마크다운 문서 안에 <script>alert(1)</script>를 그대로 적으면, 원래 스펙을 충실히 따르는 렌더러는 이를 이스케이프하지 않고 그대로 HTML로 출력합니다. 이는 버그가 아니라 "마크다운으로 표현 안 되는 서식은 HTML을 섞어 쓰라"는 설계 의도였습니다. 문제는 이 의도가 만들어진 2004년에는 "마크다운으로 작성된 서로 모르는 여러 사용자의 입력을 한 페이지에서 렌더링하는 서비스"라는 시나리오가 지금처럼 흔하지 않았다는 점입니다.
2. 실제 공격 벡터: script 태그만이 아니다
raw HTML 통과 문제를 알고 <script> 태그만 걸러내는 렌더러도 여전히 뚫릴 수 있습니다. 마크다운 자체 문법을 통해 스크립트를 실행시키는 경로가 따로 있기 때문입니다.
| 공격 벡터 | 예시 | 원리 |
|---|---|---|
| 이미지 onerror | ) | 일부 파서가 title 뒤 속성을 그대로 img 태그에 흘려보냄 |
| javascript: 스킴 | [클릭](javascript:alert(1)) | href 값 검증 없이 그대로 출력하면 클릭 시 스크립트 실행 |
| data: URI + SVG |  | SVG 내부에 <script> 삽입 가능, 이미지처럼 보이지만 실제로 스크립트 포함 |
| 참조 링크 재정의 | [1]: javascript:alert(1) | 본문과 떨어진 곳에서 링크 대상을 위험한 스킴으로 몰래 정의 |
즉 안전한 마크다운 렌더러는 단순히 <script> 문자열 하나만 걸러내는 것이 아니라, HTML 이스케이프·URL 스킴 화이트리스트(http/https/mailto 등만 허용)·속성 값 이스케이프를 동시에 처리해야 합니다.
3. CommonMark: 파편화된 마크다운을 표준화한 시도
Markdown.pl 원본 스펙은 모호한 부분이 많아, 이후 우후죽순처럼 생긴 구현체(Python-Markdown, Pandoc, GFM, Redcarpet 등)마다 같은 입력에 대해 서로 다른 HTML을 출력하는 문제가 있었습니다. 2014년 스택오버플로우 공동창업자 제프 앳우드(Jeff Atwood)와 존 맥파랜드(John MacFarlane) 등이 이 문제를 해결하기 위해 CommonMark 스펙을 만들었습니다. CommonMark는 모든 엣지 케이스에 대해 정확한 출력을 정의한 표준 명세와 테스트 스위트를 제공해, 구현체 간 동작 차이를 없애는 것을 목표로 했습니다. GitHub는 2017년 CommonMark를 기반으로 표·취소선·자동 링크·체크박스 등을 추가한 GFM(GitHub Flavored Markdown) 스펙을 공식 발표했고, 현재 대부분의 웹 마크다운 렌더러(marked.js 등)는 CommonMark 또는 GFM을 준수한다고 명시합니다.
다만 CommonMark 스펙 자체는 "raw HTML을 통과시킨다"는 원본 설계를 그대로 계승했습니다. 즉 CommonMark를 준수한다고 해서 자동으로 XSS에 안전해지는 것은 아니며, XSS 방어는 스펙 준수와는 별개로 렌더러가 추가로 구현해야 하는 계층입니다.
4. 방어 전략: 이스케이프 vs 새니타이즈
실무에서 마크다운 XSS를 막는 방법은 크게 두 갈래입니다.
- Raw HTML 전면 이스케이프: 입력에 포함된 모든
<,>,&를 처리 첫 단계에서 엔티티로 바꿔버려, 사용자가 어떤 HTML 태그를 입력해도 텍스트로만 표시되게 만드는 방식입니다. 구현이 단순하고 확실하지만, raw HTML 삽입이라는 마크다운의 기능 하나를 포기하는 셈입니다. - 렌더링 후 새니타이즈(Sanitize): 마크다운을 일단 HTML로 정상 렌더링한 뒤, DOMPurify 같은 라이브러리로 결과 DOM을 순회하며 허용 목록(allowlist)에 없는 태그·속성·URL 스킴을 제거하는 방식입니다.
<b>,<img>같은 안전한 HTML은 그대로 살리면서<script>나onerror같은 위험 요소만 골라 제거할 수 있어 더 유연하지만, 허용 목록 설계를 잘못하면 우회 경로가 남을 수 있습니다.
어느 방식이든 URL 스킴 검증(href/src 값이 http/https/mailto/tel로 시작하는지)은 별도로 반드시 필요합니다. HTML 태그를 완벽히 걸렀더라도 javascript: 스킴 링크 하나가 남아 있으면 공격이 그대로 성립하기 때문입니다.
자주 묻는 질문
Q. CommonMark를 따르면 자동으로 안전한가요?
아닙니다. CommonMark는 "같은 입력에 같은 출력"을 보장하는 문법 표준일 뿐, 보안 계층을 규정하지 않습니다. raw HTML 통과 자체가 스펙의 일부이므로, XSS 방어는 렌더러가 스펙과 별개로 직접 구현해야 합니다.
Q. script 태그만 필터링하면 충분한가요?
부족합니다. javascript: 스킴 링크, 이미지 onerror 속성, data: URI로 위장한 SVG 스크립트 등 <script> 태그 없이도 코드를 실행시키는 경로가 여러 개 있어, 태그 하나만 걸러서는 막을 수 없습니다.
Q. 개인이 마크다운을 붙여넣어 미리 보기만 할 때도 위험한가요?
본인이 직접 작성한 내용을 본인만 보는 경우 위험도는 낮습니다. 위험은 다른 사람이 입력한 마크다운을 제3자가 렌더링해서 보는 구조(댓글, 게시판, 협업 문서 등)에서 커집니다. 그런 서비스를 직접 만든다면 DOMPurify 같은 검증된 새니타이저를 반드시 함께 써야 합니다.