Markdown을 안전하게 렌더링하는 세 단계 방어선
Markdown은 신뢰할 수 없는 입력으로 취급한다
작성자가 관리자 한 명뿐이어도 Markdown 원문을 안전하다고 가정하면 안 된다. 계정 탈취, 오래된 문서의 복사·붙여넣기, 외부 데이터 import를 통해 위험한 HTML이 들어올 수 있다. 댓글보다 권한이 높다는 이유로 출력 검증을 생략하면 저장형 XSS의 영향 범위만 커진다.
안전한 파이프라인은 세 역할을 분리한다.
- CommonMark/GFM 파서가 Markdown을 HTML 구조로 바꾼다.
- Rouge가 코드 블록에 syntax highlighting class를 붙인다.
- sanitizer가 허용한 태그와 속성만 남긴다.
순서가 중요하다. sanitize한 뒤 highlighting을 하거나 이후 문자열 치환으로 HTML을 덧붙이면 sanitizer 밖에서 새 공격 표면이 생긴다.
allowlist는 기능 요구에서 역으로 만든다
sanitizer 설정에 “자주 쓰일 것 같은 태그”를 넓게 넣지 않는다. 실제 콘텐츠 기능에 필요한 태그부터 열고, 각 속성이 왜 필요한지 기록한다. 표에는 table, thead, tbody, tr, th, td, 체크리스트에는 안전한 checkbox 속성, Rouge에는 제한된 class가 필요하다.
반대로 다음 항목은 제거 대상이다.
script,iframe,object,embedonclick,onerror를 포함한 모든 event handler- 임의의
style javascript:,data:등 허용하지 않은 URL protocol- 예측하지 못한
id나 class
링크의 href와 이미지의 src는 태그를 허용하는 것만으로 충분하지 않다. protocol을 정규화하고 http, https처럼 명시한 값만 통과시켜야 한다. 대소문자와 문자 entity로 우회한 URL도 테스트한다.
안전성 계약은 공격 문자열로 테스트한다
정상 Markdown만 테스트하면 renderer가 예쁘게 보인다는 것밖에 증명하지 못한다. 실제로 차단해야 하는 payload를 입력하고 결과 DOM을 검증한다.
test "removes executable markup and unsafe urls" do
source = <<~MD
<script>alert(1)</script>
[위험한 링크](javascript:alert(1))
<img src="x" onerror="alert(1)">
MD
html = MarkdownRenderer.call(source)
assert_no_includes html, "<script"
assert_no_includes html, "javascript:"
assert_no_includes html, "onerror"
end
차단 테스트와 함께 표, 체크리스트, 코드 강조가 보존되는 정상 사례도 둔다. 보안 설정을 강화하다 필요한 콘텐츠까지 조용히 삭제하는 회귀를 막기 위해서다. 링크에 새 창을 적용한다면 rel="noopener noreferrer" 같은 연관 속성도 결과 계약에 포함한다.
출력은 한 렌더러에서만 신뢰 경계를 넘는다
상세 화면, 미리보기, RSS가 서로 다른 Markdown helper를 사용하면 한 경로만 sanitize를 빠뜨리기 쉽다. 공용 렌더러 하나를 만들고 모든 출력 경로가 그것을 호출하도록 한다. 템플릿은 sanitize 전 원문이나 중간 HTML에 html_safe를 호출하지 않는다.
렌더러가 반환하는 값을 안전한 문자열로 표시해야 한다면, 그 책임은 sanitize가 성공한 마지막 지점에만 둔다. 가능하면 타입이나 메서드 경계를 통해 “정제된 HTML”과 “사용자 원문”을 구분한다. RSS도 브라우저 페이지가 아니라고 예외로 두지 않는다. feed reader 역시 HTML을 표시하므로 동일한 방어선이 필요하다.
렌더러 변경은 캐시 버전 변경과 함께 배포한다
allowlist, heading 정규화, 링크 속성처럼 출력 계약을 변경했는데 기존 fragment cache를 유지하면 과거의 위험한 HTML이 계속 노출될 수 있다. MarkdownRenderer::CACHE_VERSION을 캐시 키에 넣고 계약 변경 시 반드시 올린다. 본문 fragment뿐 아니라 renderer 결과에서 만드는 meta description도 같은 버전을 사용해야 한다.
완료 조건에는 stale-cache 회귀를 포함한다. 이전 버전 키에 위험한 값을 미리 넣고, 새 renderer가 그 값을 사용하지 않는지 확인한다. 이후 전체 경로에서 <script>, event 속성, 위험 protocol이 남지 않는지 검사한다.
Markdown 보안은 “sanitize를 한 번 호출했다”로 끝나지 않는다. 파싱부터 강조, 정제, 캐시, RSS까지 하나의 신뢰 경계로 설계하고 공격 사례를 실행 가능한 테스트로 남겨야 유지할 수 있다.
댓글
아직 댓글이 없습니다.
댓글 남기기