XSS 보안 추가

Administrator||조회수 73

alt text


내 블로그는 정말 안전할까? XSS 방어막을 찾아 떠난 험난한 여정

💡 Cross-Site Scripting(XSS)란? 웹사이트에 악성 스크립트가 삽입되어 사용자의 브라우저에서 실행되도록 하는 보안 취약점이다. 공격자는 이를 통해 사용자의 쿠키, 세션 정보, 개인 데이터를 탈취하거나 웹 페이지를 조작할 수 있다. 특히 사용자 입력을 제대로 검증하지 않는 웹사이트에서 자주 발생하며, 방치할 경우 심각한 보안 사고로 이어진다.

1. 편리함의 그림자: 무방비로 노출된 XSS 취약점

블로그에 유튜브 동영상을 첨부하는 기능을 구현하던 중, 블로그는 Toast UI Editor로 마크다운을 작성하고, 프론트엔드에서 react-markdownrehype-raw 플러그인을 통해 렌더링하는 구조였다.

rehype-raw는 마크다운에 포함된 HTML을 실제 DOM으로 그려주는 도구였다. 이 덕분에 본문에 <iframe> 태그를 삽입하면 유튜브 영상이 문제없이 출력되었다.

<iframe src="https://www.youtube.com/embed/..."></iframe>

그러나 <iframe> 대신 악의적인 <script> 태그가 삽입되면 다음과 같은 상황이 발생했다.

<script>alert('당신의 세션 정보가 탈취되었습니다!');</script>

rehype-raw는 이를 그대로 HTML로 렌더링했고, 결과적으로 사용자가 해당 게시물을 열람하는 순간 XSS(Cross-Site Scripting) 공격이 실행되는 상태가 되었다.

2-1. 첫 번째 실패: DOMPurify와 '내용 삭제' 문제

XSS 공격을 막는 표준적인 방법은 서버 측에서 HTML을 저장하기 전에 정제(Sanitization)를 수행하는 것이다. 처음에는 DOMPurify를 백엔드에 도입했다. 그러나 테스트 과정에서 정상적인 글 내용이 대거 삭제되는 문제가 발생했다.

원인은 DOMPurify의 기본 설정이 엄격했기 때문이다. <b>, <i> 등 일부 태그만 허용하고 나머지는 모두 제거했기 때문에, react-markdown이 생성하는 <p>, <ul>, <li> 태그까지 삭제되었다. 그 결과 글의 구조가 완전히 무너졌다.

2-2. 두 번째 실패: 정밀 제어의 한계와 [object Object] 문제

이후 sanitize-html 라이브러리를 사용해 화이트리스트 정책을 직접 수립했다. 그러나 두 가지 문제가 발생했다.

  1. <iframe> 태그가 포함된 지점에서 이후 내용이 잘려나갔다.
  2. 코드 블록에서 특정 코드를 작성하면 ,[object Object],라는 문자열이 출력되었다.

원인은 HTML 전용 파서에 마크다운 원문을 그대로 입력한 것이었다. 마크다운을 이해하지 못한 파서가 파싱 오류를 일으켰고, 이로 인해 내용이 손실되거나 잘못 처리되었다.

3. 깨달음: 아키텍처적 실수

문제의 원인은 단순했다. 마크다운을 HTML로 변환한 뒤에 정제를 수행해야 했다. 이에 따라 백엔드에서 마크다운 변환 → HTML 정제라는 2단계 파이프라인을 구축했다. 데이터베이스에는 변환된 HTML을 저장하는 방식이었다.

이 방식은 <iframe>과 코드 블록에서 더 이상 문제가 발생하지 않았다. 그러나 글을 수정할 때 문제가 드러났다. 데이터베이스에 저장된 것은 원본 마크다운이 아니라 HTML이었기 때문에, 수정 시에는 수백 줄의 HTML 태그가 뒤엉킨 결과물이 노출되었다.

사용자는 마크다운 에디터에서 글을 수정하고 싶어하지, HTML을 직접 편집하길 원하지 않는다. 보안만을 고려하다가 사용자 경험과 데이터 무결성을 파괴한 셈이었다.

이 과정에서 문제의 본질은 특정 라이브러리가 아니라, 처리 위치와 시점이라는 사실을 깨달았다.

4. 세 번째 시도: 역할 분리와 아키텍처 재정립

데이터베이스에는 반드시 원본 마크다운을 저장해야 한다는 결론에 도달했다. 보안은 백엔드가 책임져야 했다.

최종적으로 아키텍처를 다음과 같이 정의했다.

1. 저장/수정 흐름 (POST, PUT)

  • 백엔드는 마크다운 원본을 그대로 데이터베이스에 저장한다.

2. 조회 흐름 (GET)

  • 백엔드는 데이터베이스에서 마크다운을 꺼낸 뒤, 사용자에게 전달하기 직전에 HTML로 변환하고 정제를 수행한다.
  • 최종적으로 안전한 HTML만을 프론트엔드에 전달한다.

3. 렌더링 흐름 (프론트엔드)

  • 프론트엔드는 백엔드가 보낸 HTML을 그대로 렌더링한다. 보안 검사는 필요하지 않다.

이 구조를 통해 데이터 무결성, 보안, 사용자 경험을 모두 충족할 수 있었다.

5. 최종 구현

5.1 백엔드: GET 핸들러에서만 보안 처리

sanitizer.tsPOST/PUT이 아니라 GET 핸들러에서만 사용되도록 변경했다.

postsRouter.get('/:postId', async (c) => { const { Item } = await ddbDocClient.send(new GetCommand(...)); const sanitizedContent = await sanitizeContent(Item.content || ''); const securedPost = { ...Item, content: sanitizedContent }; return c.json({ post: securedPost, ... }); });

5.2 프론트엔드: ReactMarkdown 제거

백엔드가 이미 HTML을 제공하므로, ReactMarkdown 대신 dangerouslySetInnerHTML을 사용했다.

export default function MarkdownViewer({ content }: { content: string }) { return ( <div className="toastui-editor-contents" dangerouslySetInnerHTML={{ __html: content }} /> ); }

React의 경고에도 불구하고, 백엔드에서 보안 검증을 마쳤기 때문에 안전하게 사용할 수 있었다.

6. 최종 결과

최종적으로 아키텍처는 다음과 같은 효과를 얻었다.

  • 유튜브 iframe은 정상적으로 작동하면서도 <script> 태그 공격은 차단되었다.
  • 데이터베이스에는 항상 마크다운 원본이 저장되었다.
  • 글 수정 시에는 원본 마크다운이 노출되어 편집이 용이했다.
  • 보안은 백엔드에서 책임지고, 프론트엔드는 단순히 렌더링만 수행했다.

이번 경험을 통해 단순히 라이브러리를 적용하는 것보다, 데이터 흐름과 계층별 역할을 올바르게 설계하는 것이 더 중요하다 는 사실을 명확히 이해하게 되었다. 블로그는 이제 안전하면서도 사용자 친화적인 공간이 되었다.


Administrator
Written by

Administrator

안녕하세요! Deep Dive! 블로그 제작자 입니다.

댓글을 불러오는 중...