리치 텍스트 에디터 HTML 저장 시 XSS 취약점 수정
목차
TipTap 에디터에서 입력된 HTML을 그대로 DB에 저장하고 있었다는 걸 코드 리뷰 중에 뒤늦게 확인했다.
왜 이게 문제였나
리치 텍스트 에디터는 기본적으로 HTML을 그대로 생산한다. TipTap도 마찬가지다. editor.getHTML()로 꺼낸 결과물은 그 자체로 유효한 마크업이지만, 동시에 <script> 태그나 onerror 핸들러 같은 악성 페이로드를 그대로 품을 수도 있다. 사용자가 직접 에디터를 쓰는 게 아니라 운영자만 쓰는 어드민 인터페이스라 해도 문제는 동일하다. 저장된 HTML이 결국 클라이언트에서 dangerouslySetInnerHTML 혹은 이와 유사한 방식으로 렌더링되는 순간, 저장 시점에 들어간 악성 코드가 그대로 실행될 수 있다.
XSS는 크게 세 종류로 나뉜다.
| 유형 | 설명 | TipTap 관련 위협 |
|---|---|---|
| Stored XSS | DB에 저장된 악성 스크립트가 페이지 로드 시 실행 | 직접 해당 |
| Reflected XSS | URL 파라미터 등을 통해 즉시 반사 | 간접적 |
| DOM-based XSS | 클라이언트 JS가 DOM을 직접 조작 | 렌더링 방식에 따라 해당 |
이 케이스는 명백히 Stored XSS 경로였다. 에디터에서 만들어진 HTML이 API를 거쳐 DB에 박히고, 이후 페이지 렌더링 시 그게 그대로 클라이언트에 내려가는 흐름이었으니.
어떻게 잡았나
package.json과 package-lock.json이 변경 파일에 포함돼 있으니 sanitize 라이브러리를 새로 추가한 것. 그리고 변경된 라우트 파일이 총 네 개다.
src/app/api/admin/pages/[id]/route.ts ← 페이지 수정 API
src/app/api/admin/pages/route.ts ← 페이지 생성 API
src/app/api/admin/posts/[id]/route.ts ← 포스트 수정 API
src/app/api/admin/posts/route.ts ← 포스트 생성 API
생성과 수정, 그리고 pages와 posts 두 도메인 모두 커버했다는 게 중요하다. 한쪽만 막으면 반쪽짜리 수정이 된다. 이런 류의 보안 픽스는 같은 패턴을 쓰는 모든 진입점을 한 번에 닫아야 한다. 하나 남기면 그게 구멍이다.
패턴 자체는 단순하다. API 핸들러에서 body를 파싱한 뒤, HTML 필드를 DB에 넘기기 전에 sanitize를 한 번 거치는 것.
import DOMPurify from 'isomorphic-dompurify';
// 예시 패턴
const sanitizedContent = DOMPurify.sanitize(rawContent, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'strong', 'em', 'a', 'blockquote', 'code', 'pre', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel'],
});
await db.save({ content: sanitizedContent });
allowlist 방식이 핵심이다. "이것만 허용"하는 방식이 "이것만 차단"하는 방식보다 훨씬 안전하다. 차단 목록은 항상 새로운 우회 기법에 뚫릴 가능성이 있지만, 허용 목록은 명시적으로 열어준 것 외엔 모두 제거된다.
회고
솔직히 말하면 이건 초기 설계 때 잡혔어야 했다. 에디터 연동 작업을 진행할 때 "어드민 전용이니까"라는 암묵적인 가정이 sanitize 단계를 건너뛰게 만든 것 같다. 팀 리뷰에서도 기능 동작 위주로 봤지 보안 레이어까지 꼼꼼히 들여다보지 못했다.
앞으로 리치 텍스트 입력이 관여된 PR은 체크리스트에 "저장 전 sanitize 처리 여부" 항목을 명시적으로 넣을 생각이다. 코드 리뷰 때 "잘 되네요" 한 마디로 넘어가기엔 너무 조용하게 터질 수 있는 구멍이다.
그리고 sanitize는 서버 사이드에서 하는 게 맞다. 클라이언트에서 미리 정제하더라도 API 쪽에서 한 번 더 검증하는 게 방어 심층화(defense in depth) 관점에서 올바른 구조다. 클라이언트 코드는 언제든 우회될 수 있다는 전제로 서버를 설계해야 한다.
pages와 posts 두 도메인의 생성/수정 네 엔드포인트를 동시에 커버한 건 잘한 판단이었다. 이런 수정은 절대 분할 커밋하면 안 된다. 하나 고치고 다른 건 "나중에"라고 남겨두면, 그 "나중에"가 영원히 안 오거나 그 사이에 터진다.
끝.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.