DOM 기반 XSS 방어: 프레임워크 사례와 필터 컴포넌트 개발

프론트엔드에서의 DOM 기반 XSS 방어: 2가지 프레임워크 사례 + 1개 필터 컴포넌트

"3년간 프론트엔드 개발을 했지만, Vue/React에만 익숙해서 보안 전환 시 'DOM 기반 XSS'에 대해 구체적인 방어 전략을 설명하지 못하는 경우가 많습니다." 이는 프론트엔드 개발자가 보안 분야로 전환할 때 겪는 대표적 어려움입니다. DOM 기반 XSS는 프론트엔드 개발자에게 자연스러운 영역입니다. 이는 백엔드를 거치지 않고 프론트엔드의 DOM 조작(예: document.write, innerHTML)으로 발생하며, 사용하는 프레임워크 API(v-html, dangerouslySetInnerHTML)가 주요 위험 요소입니다. 본문에서는 익숙한 Vue3, React18을 활용한 실전 사례와 일반적인 필터 컴포넌트를 소개하며, 프론트엔드 기술을 그대로 활용해 3시간 내에 DOM 기반 XSS 방어 기술을 습득할 수 있습니다.

1. 기본 개념: DOM 기반 XSS의 핵심 원리

1. 다른 XSS 유형과의 차이점

XSS 유형발생 경로프론트엔드 책임
반사형/저장형프론트→백엔드→프론트(백엔드 참여)백엔드 필터링 보조(예: 파라미터 전달)
DOM 기반순수 프론트엔드(입력→DOM 조작→스크립트 실행)프론트엔드 완전 관리(핵심 방어 책임)

2. 프론트엔드에서 자주 발생하는 시나리오

  • 동적 DOM 생성: document.write(location.hash)로 URL 해시 값 렌더링 시 악성 스크립트 실행 가능
  • 위험 API 사용: Vue의 v-html, React의 dangerouslySetInnerHTML로 필터되지 않은 사용자 입력 렌더링
  • 이벤트 바인딩: element.onclick = "handleClick('"+ userInput +"')" 형식으로 사용자 입력을 조합할 경우, 입력값에 ');alert(1);// 포함 시 스크립트 삽입 가능성

3. 예제: 1줄 코드로 DOM 기반 XSS 재현

HTML에 다음 코드를 작성하고 http://localhost/test.html#%3Cscript%3Ealert(‘DOM-XSS’) 접근 시 팝업 발생:
<!-- 보안 취약 코드: location.hash 필터 없음 -->
<script>
const userInput = location.hash.slice(1);
document.write(`입력 내용: ${userInput}`); // 필터 없음
</script>

2. 프레임워크 사례 1: Vue3에서의 DOM 기반 XSS 방어

1. 취약 시나리오 (자주 사용하는 코드)

상품 상세 페이지에서 사용자가 제출한 HTML 형식의 설명(예:

태그 포함)을 v-html로 렌더링하는 경우:

<!-- Vue3 취약 컴포넌트: ProductDetail.vue -->
<template>
  <div class="product-detail">
    <div v-html="productDesc" class="desc"></div>
    <div id="dynamicBtnContainer"></div>
  </template>

<script setup>
import { onMounted, ref } from 'vue';

const productDesc = ref(`
  <p>상품 매우 좋음</p>
  <img src=x onerror=alert('DOM-XSS-1')>
`);

onMounted(() => {
  const btnText = new URLSearchParams(window.location.search).get('btnText') || '구매';
  const btnHtml = `<button onclick="alert('DOM-XSS-2')">${btnText}</button>`;
  document.getElementById('dynamicBtnContainer').innerHTML = btnHtml;
});
</script>
http://localhost:5173/product?id=1&btnText=악의적인 버튼’);alert(1);// 접근 시 2회 팝업 발생.

2. 방어 방법: 2단계로 해결

단계 1: DOMPurify로 HTML 필터링
- 핵심 아이디어: DOMPurify는 HTML 태그를 보존하고 악성 스크립트를 제거하는 라이브러리 - 설치:
npm install dompurify @types/dompurify --save
- 필터링 함수 구현:
<!-- Vue3 방어 컴포넌트: ProductDetail.vue (수정 버전) -->
<template>
  <div class="product-detail">
    <div v-html="safeProductDesc" class="desc"></div>
    <div id="dynamicBtnContainer"></div>
  </template>

<script setup>
import { onMounted, ref, computed } from 'vue';
import DOMPurify from 'dompurify';

const productDesc = ref(`<p>상품 매우 좋음</p><img src=x onerror=alert('DOM-XSS-1')>`);

const safeProductDesc = computed(() => {
  return DOMPurify.sanitize(productDesc.value, {
    ADD_TAGS: [],
    ADD_ATTR: [],
    FORBID_ATTR: ['onerror', 'onclick']
  });
});

onMounted(() => {
  const btnText = new URLSearchParams(window.location.search).get('btnText') || '구매';
  const btn = document.createElement('button');
  btn.textContent = DOMPurify.sanitize(btnText);
  btn.addEventListener('click', () => {
    alert('정상 구매 로직');
  });
  document.getElementById('dynamicBtnContainer').appendChild(btn);
});
</script>
단계 2: 효과 검증
- 원래 취약 URL 접근 시: - onerror 이벤트 제거, 이미지 태그는 유지하지만 팝업 방지 - 버튼 텍스트의 ');alert(1);//는 전달되지 않음

3. 프레임워크 사례 2: React18에서의 DOM 기반 XSS 방어

1. 취약 시나리오 (React 일반 코드)

댓글 섹션에서 사용자 입력을 dangerouslySetInnerHTML로 렌더링하는 경우:
// React18 취약 컴포넌트: CommentSection.jsx
import { useEffect, useState } from 'react';

export default function CommentSection() {
  const [comments, setComments] = useState([
    { id: 1, content: '<p>좋아요!</p><script>alert("DOM-XSS-3")</script>' }
  ]);
  const [username, setUsername] = useState('');

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    setUsername(params.get('username') || '익명');
  }, []);

  return (
    <div className="comment-section">
      {comments.map(comment => (
        <div key={comment.id} dangerouslySetInnerHTML={{ __html: comment.content }} />
      ))}
      <div dangerouslySetInnerHTML={{ __html: `환영합니다, ${username}!` }} />
    </div>
  );
}
http://localhost:3000/comments?username=<img src=x οnerrοr=alert(‘DOM-XSS-4’)> 접근 시 2회 팝업 발생.

2. 방어 방법: 3가지 핵심 조치

단계 1: DOMPurify로 dangerouslySetInnerHTML 필터링
// React18 방어 컴포넌트: CommentSection.jsx (수정 버전)
import { useEffect, useState } from 'react';
import DOMPurify from 'dompurify';

export default function CommentSection() {
  const [comments, setComments] = useState([
    { id: 1, content: '<p>좋아요!</p><script>alert("DOM-XSS-3")</script>' }
  ]);
  const [username, setUsername] = useState('');

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const rawUsername = params.get('username') || '익명';
    setUsername(DOMPurify.sanitize(rawUsername));
  }, []);

  const renderSafeHtml = (html) => {
    return { __html: DOMPurify.sanitize(html, {
      FORBID_TAGS: ['script', 'iframe'],
      FORBID_ATTR: ['onerror', 'onload']
    }) };
  };

  return (
    <div className="comment-section">
      {comments.map(comment => (
        <div key={comment.id} dangerouslySetInnerHTML={renderSafeHtml(comment.content)} />
      ))}
      <div>환영합니다, {username}!</div>
    </div>
  );
}
단계 2: 주요 방어 원칙
  • 비HTML 데이터는 dangerouslySetInnerHTML 사용 금지: "환영합니다" 같은 텍스트는 {username}로 렌더링
  • HTML 데이터는 renderSafeHtml로 필터링: 중복 코드 방지
  • URL 파라미터는 필터 필수: location.search, location.hash로 받은 입력은 DOMPurify 처리

4. 일반 필터 컴포넌트: DOM 기반 XSS 방어 도구

프레임워크 독립형 DomXssDefender 컴포넌트 구현, Vue/React/원시 JS 프로젝트에서 사용 가능.

1. 컴포넌트 코드 (src/utils/DomXssDefender.js)

import DOMPurify from 'dompurify';

class DomXssDefender {
  static filterInput(input, options = {}) {
    if (typeof input !== 'string') return input;
    const defaultOptions = {
      FORBID_TAGS: ['script', 'iframe', 'svg'],
      FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'],
      ADD_TAGS: [],
      ADD_ATTR: []
    };
    return DOMPurify.sanitize(input, { ...defaultOptions, ...options });
  }

  static renderSafeHtml(element, html, filterOptions = {}) {
    if (!(element instanceof HTMLElement)) {
      console.error('DomXssDefender: 대상이 DOM 요소가 아닙니다');
      return;
    }
    const safeHtml = this.filterInput(html, filterOptions);
    element.innerHTML = safeHtml;
  }

  static blockDangerousApis() {
    document.write = function() {
      console.warn('DomXssDefender: document.write 사용 금지');
    };
    window.eval = function() {
      console.warn('DomXssDefender: eval 사용 금지');
      return null;
    };
  }
}

export default DomXssDefender;

2. 사용 예시 (다중 프레임워크 지원)

예시 1: 원시 JS 프로젝트
<script type="module">
import DomXssDefender from './src/utils/DomXssDefender.js';

DomXssDefender.blockDangerousApis();

const userInput = location.hash.slice(1);
const safeInput = DomXssDefender.filterInput(userInput);
document.body.textContent = `안전한 입력: ${safeInput}`;

const container = document.getElementById('container');
DomXssDefender.renderSafeHtml(container, '<p>안전한 내용</p><script>alert(1)</script>');
</script>

3. 이력서 작성 팁

- 예시: "Vue3 상품 상세 페이지에서 v-html 및 동적 DOM 생성으로 인한 DOM 기반 XSS 취약점을 발견, DOMPurify를 사용해 onerror 등 5가지 위험 이벤트 차단, addEventListener로 이벤트 바인딩 대체로 3가지 스크립트 주입 차단, 10만 이상 사용자 페이지 보안 강화"

5. 프론트엔드 전환 시 주의사항

  • 프레임워크 자체 보안만 신뢰: Vue/React는 텍스트 렌더링만 자동 전환, v-html/dangerouslySetInnerHTML, innerHTML 등은 수동 필터 필요
  • 백엔드 필터 의존 금지: URL 해시 등 프론트엔드에서 발생하는 DOM 기반 XSS는 백엔드로 감지 불가
  • DOMPurify 설정 오류: 기본 허용 태그로도 XSS 가능, FORBID_TAGS 설정 필수

6. 프론트엔드 전환의 강점

  • 기술 스택 재사용: Vue/React, npm, 컴포넌트 개발 등 익숙한 기술
  • 위험 요소 이해: v-html, innerHTML 등 위험 API와 텍스트 렌더링 시나리오 이해
  • 빠른 적용: 방어 코드는 직접 프로젝트에 통합 가능

7. 실습 제안

  1. 기존 프로젝트에서 v-html/dangerouslySetInnerHTML 검색 후 DOMPurify 필터 적용
  2. DomXssDefender 컴포넌트 통합 및 사용 문서 작성
  3. DOM 기반 XSS 취약점 복제 후 방어 방식으로 수정, 화면 녹화

태그: vue3 React18 DOMPurify XSS WebSecurity

6월 21일 19:28에 게시됨