XSS 취약점이란? React·에디터·마크다운의 빈틈과 방어법

XSS 취약점이 무엇인지, React를 써도 dangerouslySetInnerHTML·리치 텍스트 에디터·마크다운·javascript: 링크에서 다시 생기는 이유를 취약한 코드와 고친 코드로 비교합니다. DOMPurify와 CSP 적용법도 함께 정리했습니다.

보안 점검 의뢰 팀 17분 분량

XSS(Cross-Site Scripting) 취약점은 공격자가 넣은 입력이 다른 사용자의 브라우저에서 스크립트로 실행되는 문제입니다. 실행된 스크립트는 그 사용자의 권한으로 동작하므로 화면 조작, 세션을 이용한 요청 대행, 입력값 탈취 같은 일이 가능해집니다. 원인은 단순합니다. 데이터가 들어가야 할 자리에 코드가 들어갈 수 있게 만든 것입니다.

React, Vue 같은 프레임워크는 {value} 형태로 출력하는 값을 자동으로 이스케이프하므로 기본 경로에서는 XSS가 잘 생기지 않습니다. 그런데도 실제 점검에서는 꾸준히 발견됩니다. 프레임워크가 이스케이프하지 않는 경로가 서비스 곳곳에 있기 때문입니다. dangerouslySetInnerHTML, 리치 텍스트 에디터에서 저장한 HTML, 마크다운 렌더링 결과, 사용자가 입력한 링크의 href가 대표적입니다. OWASP XSS 방지 치트시트도 프레임워크의 자동 이스케이프에는 이런 "탈출구(escape hatch)"라는 빈틈이 있다고 명시합니다.

이 글에서는 이 네 가지 지점을 취약한 코드와 고친 코드로 비교하고, 마지막 방어선인 CSP(Content Security Policy)를 어떻게 단계적으로 적용하는지 정리합니다.

React가 막아주는 것과 막지 못하는 것

React는 JSX 안에서 {}로 출력하는 문자열을 텍스트로 취급합니다. 아래 코드에서 comment에 <img src=x onerror=...>가 들어 있어도 태그로 해석되지 않고 글자 그대로 보입니다.

// 안전: React가 텍스트로 이스케이프한다
function Comment({ comment }: { comment: string }) {
  return <p>{comment}</p>;
}

문제는 이 보호가 "JSX 텍스트 노드와 일반 속성 값"에만 적용된다는 점입니다. 다음 경로는 React의 자동 이스케이프 밖에 있습니다.

  • dangerouslySetInnerHTML로 HTML 문자열을 그대로 넣는 경우
  • ref.current.innerHTML, insertAdjacentHTML, document.write처럼 DOM을 직접 조작하는 경우
  • href, src에 사용자가 입력한 URL을 넣는 경우(스킴 검사 문제)
  • 서버 렌더링 시 초기 상태를 <script> 태그 안에 문자열로 직렬화하는 경우
  • 서드파티 위젯·에디터 라이브러리가 내부에서 HTML을 다루는 경우

점검할 때는 "React를 쓰니까 안전하다"가 아니라 "HTML 문자열이 DOM에 들어가는 지점이 어디인가"를 찾는 것이 출발점입니다. 코드베이스에서 dangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, href={를 검색하는 것만으로도 후보 목록 대부분이 나옵니다.

dangerouslySetInnerHTML: 이름 그대로 위험한 지점

React 공식 문서는 이 속성에 대해 "완전히 신뢰할 수 있는 출처가 아니라면 XSS 취약점을 만들기 매우 쉽다"고 경고하고, 신뢰할 수 있고 정화(sanitize)된 데이터에만 쓰라고 안내합니다. 실무에서 흔한 패턴은 공지사항·이벤트 본문처럼 "관리자만 쓰니까 괜찮다"고 판단한 HTML입니다. 하지만 관리자 계정이 탈취되거나, 같은 컴포넌트가 나중에 사용자 입력에 재사용되면 그대로 노출됩니다.

// 취약: 서버에서 받은 HTML을 그대로 삽입
function Notice({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// 개선: 허용 목록 기반으로 정화한 뒤 삽입
import DOMPurify from 'dompurify';

const ALLOWED = {
  ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'h2', 'h3', 'blockquote', 'img'],
  ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
};

function sanitize(html: string) {
  return { __html: DOMPurify.sanitize(html, ALLOWED) };
}

function Notice({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={sanitize(html)} />;
}

DOMPurify는 기본 설정만으로도 <script>, on* 이벤트 속성, javascript: 링크 등을 제거하지만, 서비스에서 실제로 필요한 태그만 허용 목록으로 좁혀 두면 예상치 못한 우회 여지가 줄어듭니다. React 문서도 {__html} 객체를 HTML이 만들어지는 곳 가까이에서 생성하라고 권하는데, 위처럼 sanitize() 함수 하나를 통해서만 객체를 만들게 하면 리뷰할 때 "정화 없이 들어간 곳"을 찾기 쉬워집니다.

주의할 점이 두 가지 있습니다. OWASP는 정화한 뒤에 문자열을 다시 가공하면 보호가 무력화될 수 있다고 경고합니다. 정화는 항상 DOM에 넣기 직전 마지막 단계에 두세요. 또 정화 라이브러리도 우회 기법이 계속 보고되므로 DOMPurify 버전을 정기적으로 올려야 합니다.

리치 텍스트 에디터: 클라이언트 정화만으로는 부족하다

게시판, 상품 설명, 블로그 기능에 WYSIWYG 에디터를 붙이면 결국 사용자가 만든 HTML을 저장하고 다른 사용자에게 보여주게 됩니다. 저장형(Stored) XSS가 가장 자주 나오는 구조입니다.

흔한 실수는 에디터 화면에서만 정화하고 서버는 받은 HTML을 그대로 저장하는 것입니다. 공격자는 브라우저 에디터를 거치지 않고 API에 직접 요청을 보낼 수 있으므로, 클라이언트 쪽 정화는 사용자 편의 기능일 뿐 방어선이 아닙니다.

// 취약: 클라이언트가 보낸 HTML을 그대로 저장
app.post('/api/posts', async (req, res) => {
  await db.post.create({ data: { body: req.body.body } });
  res.sendStatus(201);
});
// 개선: 서버에서 허용 목록으로 정화한 결과만 저장
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';

const DOMPurify = createDOMPurify(new JSDOM('').window);

const POLICY = {
  ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'src', 'alt'],
};

app.post('/api/posts', async (req, res) => {
  const clean = DOMPurify.sanitize(String(req.body.body ?? ''), POLICY);
  await db.post.create({ data: { body: clean } });
  res.sendStatus(201);
});

판단 기준은 이렇습니다.

  • 저장 시 정화(서버): 필수입니다. 다른 클라이언트(앱, 관리자 도구, 이메일 발송)가 같은 데이터를 쓰기 때문입니다.
  • 출력 시 정화(클라이언트): 가능하면 함께 둡니다. 정화 정책이 바뀌었을 때 과거에 저장된 데이터까지 보호할 수 있습니다.
  • 허용 태그 결정: 에디터 툴바에 있는 기능만 허용합니다. 툴바에 없는 <iframe>, <style>, <form>을 허용할 이유는 없습니다.
  • 에디터 라이브러리 업데이트: 에디터 자체의 붙여넣기 처리나 플러그인에서 취약점이 보고되는 경우가 있으므로 의존성 알림을 켜 둡니다.

관리자 페이지에서 사용자 게시물을 렌더링하는 경우는 피해가 더 클 수 있으니 같은 정화 함수를 쓰는지 따로 확인하세요. 관련 항목은 관리자 페이지 보안 체크리스트에도 정리해 두었습니다.

마크다운 렌더링: 라이브러리마다 기본값이 다르다

"마크다운은 HTML이 아니니까 안전하다"는 오해가 많습니다. 대부분의 마크다운 문법은 HTML 태그를 직접 쓰는 것도 허용하고, 링크 문법으로 javascript: 스킴을 넣을 수도 있습니다. 결과가 안전한지는 어떤 라이브러리를 어떤 옵션으로 쓰느냐에 달려 있습니다.

marked처럼 HTML 문자열을 반환하는 라이브러리

marked 공식 문서는 사용법 맨 앞에 "출력 HTML을 정화하지 않는다"고 경고하고, DOMPurify 같은 정화 도구로 결과를 처리하라고 권합니다.

// 취약: 마크다운 → HTML 변환 결과를 그대로 삽입
import { marked } from 'marked';

<div dangerouslySetInnerHTML={{ __html: marked.parse(userMarkdown) as string }} />
// 개선: 변환 결과를 정화한 뒤 삽입
import { marked } from 'marked';
import DOMPurify from 'dompurify';

const html = DOMPurify.sanitize(marked.parse(userMarkdown) as string);
<div dangerouslySetInnerHTML={{ __html: html }} />

react-markdown처럼 React 요소로 렌더링하는 라이브러리

react-markdown은 기본 설정에서 원시 HTML을 렌더링하지 않고 위험한 URL도 걸러 주므로 비교적 안전합니다. 문제는 "HTML 태그도 보이게 해 달라"는 요구로 rehype-raw를 추가할 때입니다. rehype-raw 문서는 이 플러그인을 사용자 콘텐츠와 함께 쓰려면 반드시 rehype-sanitize를 같이 쓰라고 안내합니다.

// 취약: 원시 HTML을 허용했지만 정화가 없다
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{userMarkdown}</ReactMarkdown>
// 개선: 정화 플러그인을 마지막에 둔다
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';

<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{userMarkdown}</ReactMarkdown>

순서가 중요합니다. 정화 이후에 실행되는 플러그인이 노드를 추가하면 그 노드는 정화를 거치지 않습니다. 또 urlTransform을 직접 재정의해 기본 URL 검사를 꺼 버리는 코드가 없는지도 확인하세요. 최근에는 LLM 응답을 마크다운으로 렌더링하는 화면이 늘었는데, 모델 출력도 외부 입력의 일종으로 보고 같은 기준을 적용해야 합니다.

href와 URL: 이스케이프로 막을 수 없는 자리

<a href={url}>에서 React는 속성 값을 이스케이프하지만, javascript:alert(1) 같은 값은 이스케이프할 특수문자가 없으므로 그대로 들어갑니다. 프로필의 "웹사이트" 필드, 게시글의 외부 링크, 리다이렉트 파라미터가 대표적인 입력 지점입니다.

React는 16.9부터 javascript: URL에 경고를 출력했고, OWASP 치트시트에 따르면 React 19는 src와 href의 javascript: URL을 차단합니다. 다만 OWASP도 지적하듯 이것이 자체 URL 검증을 대체하지는 않습니다. 구버전 React를 쓰는 서비스가 여전히 많고, window.location.href = url이나 window.open(url)처럼 JSX 밖에서 URL을 쓰는 코드에는 이 보호가 적용되지 않기 때문입니다.

// 취약: 사용자 입력 URL을 그대로 사용
<a href={profile.website}>웹사이트</a>
// 개선: URL 파서로 해석한 뒤 스킴을 허용 목록으로 검사
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);

export function safeHref(input: string): string {
  try {
    const url = new URL(input, 'https://example.com');
    return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : '#';
  } catch {
    return '#';
  }
}

<a href={safeHref(profile.website)} rel="noopener noreferrer" target="_blank">웹사이트</a>

문자열이 javascript:로 시작하는지만 보는 차단 목록 방식은 대소문자, 공백, 제어 문자 변형에 약합니다. 브라우저와 같은 방식으로 해석하는 URL 파서를 쓰고, 허용할 스킴만 명시하는 편이 안전합니다. 저장 시점(서버)과 출력 시점(클라이언트) 양쪽에서 같은 함수를 쓰면 더 좋습니다. ESLint의 react/jsx-no-script-url 규칙을 켜 두면 코드에 직접 쓴 javascript: URL도 잡을 수 있습니다.

SSR 초기 상태 직렬화도 함께 확인

서버 렌더링에서 초기 데이터를 <script>window.__STATE__ = ${JSON.stringify(state)}</script>처럼 넣으면, 데이터 안의 </script> 문자열이 스크립트 블록을 닫아 버릴 수 있습니다. 프레임워크가 제공하는 직렬화 방식을 쓰거나, 직접 넣어야 한다면 <를 <로 치환해 출력하세요. Next.js를 쓴다면 Next.js 보안 가이드도 함께 참고할 만합니다.

CSP: 놓친 한 곳을 위한 마지막 방어선

지금까지의 방어는 모두 "위험한 지점을 빠짐없이 찾는다"는 전제에 기대고 있습니다. CSP는 하나를 놓쳤을 때 삽입된 스크립트가 실행되지 않게 만드는 브라우저 정책입니다. OWASP는 CSP를 주 방어 수단이 아니라 다른 방어 위에 얹는 심층 방어로 보라고 권합니다.

Google의 web.dev 가이드가 권하는 방식은 도메인 허용 목록이 아니라 nonce 기반의 엄격한 CSP(strict CSP)입니다. 요청마다 새 난수(nonce)를 만들고, 그 값을 가진 <script>만 실행되게 합니다.

// Express 예시: 요청마다 nonce를 생성해 CSP 헤더에 넣는다
import crypto from 'node:crypto';

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader(
    'Content-Security-Policy-Report-Only',
    [
      `script-src 'nonce-${nonce}' 'strict-dynamic'`,
      "object-src 'none'",
      "base-uri 'none'",
      'report-uri https://example.com/csp-report',
    ].join('; ')
  );
  next();
});
<!-- 템플릿: 서비스가 직접 넣는 스크립트에만 nonce를 붙인다 -->
<script nonce="<%= nonce %>" src="/static/app.js"></script>

'strict-dynamic'은 nonce로 허용된 스크립트가 동적으로 불러오는 스크립트도 실행을 허용해 번들러·태그 매니저 환경에서 적용 부담을 줄여 줍니다. 공격자가 삽입한 인라인 스크립트나 onerror 같은 인라인 이벤트 핸들러는 nonce가 없으므로 실행되지 않습니다.

적용 순서는 다음을 권합니다.

  1. Content-Security-Policy-Report-Only 헤더로 먼저 배포해 위반 리포트만 수집합니다. 리포트 전용 모드는 <meta> 태그로는 쓸 수 없으니 헤더로 설정합니다.
  2. 리포트를 보며 인라인 이벤트 핸들러(onclick="...")를 addEventListener로 옮기고, 서드파티 스크립트에 nonce를 붙입니다.
  3. 위반이 정리되면 Content-Security-Policy 헤더로 바꿔 차단 모드로 전환합니다.
  4. 정적 호스팅처럼 요청마다 nonce를 만들 수 없는 환경이라면 해시 기반 CSP를 검토합니다.

nonce는 반드시 요청마다 새로 만들어야 하고, CDN에서 HTML을 캐시하면 nonce가 재사용될 수 있으니 캐시 정책도 함께 확인하세요.

출시 전 XSS 점검 체크리스트

  • 코드에서 dangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, document.write 사용처를 모두 찾았고, 각각 정화 함수를 거친다
  • 정화는 DOM 삽입 직전 마지막 단계에서 하고, 정화 이후 문자열을 다시 가공하지 않는다
  • 리치 텍스트 에디터 HTML은 서버에서 허용 목록 기반으로 정화한 뒤 저장한다
  • 마크다운 라이브러리의 원시 HTML 허용 여부를 확인했고, 허용한다면 정화 플러그인이 마지막에 실행된다
  • 사용자 입력 URL은 URL 파서와 스킴 허용 목록(http:, https:, 필요 시 mailto:)으로 검증한다
  • window.location, window.open, 리다이렉트 파라미터에도 같은 URL 검증을 적용한다
  • SSR 초기 상태를 <script>에 넣을 때 <를 이스케이프한다
  • 관리자 화면, 이메일 템플릿, 앱 웹뷰처럼 같은 데이터를 다른 곳에서 렌더링하는 경로도 확인했다
  • DOMPurify, 에디터, 마크다운 라이브러리의 버전 업데이트 알림을 받고 있다
  • CSP를 Report-Only로 배포해 위반 리포트를 수집하고 있거나, 차단 모드로 운영 중이다

자동 스캐너는 반사형 XSS는 비교적 잘 찾지만, 저장 후 다른 화면에서 렌더링되는 저장형 XSS나 특정 권한 화면에서만 보이는 경로는 놓치기 쉽습니다. 이런 한계는 웹 취약점 스캐너 결과가 깨끗할 때 더 봐야 할 곳에서 자세히 다뤘습니다.

자주 묻는 질문

React 19로 올리면 XSS 걱정은 끝나나요?

아닙니다. React 19는 href, src의 javascript: URL을 차단하지만, dangerouslySetInnerHTML과 DOM 직접 조작, 에디터·마크다운 HTML은 여전히 개발자가 정화해야 합니다. 업그레이드는 도움이 되지만 URL 검증과 HTML 정화를 대신하지는 않습니다.

입력할 때 <, >를 모두 지워서 저장하면 되지 않나요?

일반 텍스트 필드라면 저장은 원문 그대로 하고 출력 시 이스케이프하는 편이 정확합니다. 입력 단계에서 문자를 지우면 정상 데이터가 깨지고, 출력 맥락(HTML 본문, 속성, URL, 스크립트)마다 필요한 처리가 달라 한 번의 필터로 모두 막을 수 없습니다. HTML이 필요한 필드만 허용 목록 기반 정화를 적용하세요.

CSP만 제대로 설정하면 정화는 생략해도 되나요?

권하지 않습니다. CSP는 브라우저 지원 차이, 서드파티 스크립트 예외, 설정 실수 때문에 빈틈이 생길 수 있습니다. OWASP도 CSP를 주 방어 수단으로 삼지 말라고 권합니다. 정화와 URL 검증이 1차 방어, CSP가 놓친 경우를 위한 2차 방어입니다.

정리

  • XSS 취약점은 데이터가 코드로 실행되는 문제이며, React의 자동 이스케이프는 JSX 텍스트와 일반 속성 값에만 적용됩니다.
  • dangerouslySetInnerHTML, 리치 텍스트 에디터, 마크다운 렌더링, 사용자 입력 URL은 프레임워크 보호 밖에 있으므로 DOMPurify 같은 허용 목록 기반 정화와 스킴 검증을 직접 적용해야 합니다.
  • 에디터 HTML은 서버에서 정화해 저장하고, 출력 직전에 한 번 더 정화하는 구조가 안전합니다.
  • nonce 기반 CSP를 Report-Only부터 단계적으로 도입하면 놓친 지점에 대한 마지막 방어선이 생깁니다. 직접 점검이 어렵다면 무료 분석 신청으로 현재 상태를 진단받아 볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글