프론트엔드에서의 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. 실습 제안
- 기존 프로젝트에서 v-html/dangerouslySetInnerHTML 검색 후 DOMPurify 필터 적용
- DomXssDefender 컴포넌트 통합 및 사용 문서 작성
- DOM 기반 XSS 취약점 복제 후 방어 방식으로 수정, 화면 녹화