FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · 높음
보안 연구 · FlawOpen

JavaScript XSS: DOM·React·Express 보호

안전하지 않은 DOM/React 싱크와 링크는 JavaScript를 실행할 수 있습니다. 텍스트 API를 쓰고 HTML 정화, URL 검증, Trusted Types를 적용하세요(CWE-79).

💡 알기 쉬운 설명 (ELI5)

극장의 큐시트 제어판을 떠올려 보세요. 무대 감독이 관객의 메모를 모두 무대 지시로 처리하면 예상치 못한 장면이 추가될 수 있지만, 일반 대사로 표시하면 텍스트로 남습니다. 브라우저에서 innerHTML과 React의 dangerouslySetInnerHTML은 DOM에 입력을 마크업으로 파싱하라고 지시하고, textContent는 그대로 문자로 표시합니다. 일반 텍스트는 이스케이프하고, 의도적으로 허용할 소량의 HTML만 정화하며, 링크를 만들기 전에 URL 스킴을 검증하세요.

핵심 개념 및 용어

innerHTML vs textContent
innerHTML, outerHTML, insertAdjacentHTML에 값을 넣으면 브라우저가 문자열을 HTML로 파싱하므로, 그 안의 <img onerror>는 살아 있는 요소가 됩니다. textContent와 createTextNode는 텍스트만 넣으며, 값의 어떤 부분도 마크업으로 파싱되지 않습니다.
Template literals in res.send()
Express의 res.send()는 문자열을 기본적으로 text/html로 보냅니다. 템플릿 리터럴은 전송 전에 ${req.query.q}를 평가하므로 응답에는 사용자가 쿼리 문자열에 넣은 것이 그대로 담깁니다. req.query.q는 배열이나 객체일 수도 있으니 타입을 확인하세요.
dangerouslySetInnerHTML + DOMPurify
React는 JSX의 텍스트를 자동으로 이스케이프하며, dangerouslySetInnerHTML은 명시적인 예외입니다. 값을 DOMPurify.sanitize()에 통과시키면, 브라우저 자체 파서로 파싱한 뒤 React가 삽입하기 전에 스크립트, 이벤트 핸들러, 위험한 URL을 제거합니다.
javascript: URLs in href
JSX는 속성 값을 이스케이프하지만 href="javascript:..."는 클릭하면 코드를 실행하는 유효한 URL입니다. new URL()로 파싱하고 http:와 https:만 허용하세요. URL 파서는 단순한 접두사 검사가 놓치는 앞쪽 공백과 탭을 제거합니다.
Trusted Types
CSP 지시어 require-trusted-types-for 'script'를 쓰면 지원 브라우저는 innerHTML 같은 싱크에 일반 문자열을 거부하고, 직접 정의한 정책(보통 DOMPurify를 호출하는 정책)이 만든 TrustedHTML 객체만 받아들입니다. 새로운 싱크가 모르는 사이에 끼어들 수 없습니다.

프레임워크 매핑

단계별 공격 실행 흐름

단계 1

입력이 싱크에 도달

검색어, API에서 불러온 프로필 필드, 다른 사용자가 저장한 링크가 문자열로 도착해 innerHTML, dangerouslySetInnerHTML, href, res.send()에 전달됩니다.

단계 2

값이 마크업이 됨

무해한 테스트 값 <img src=x onerror=alert(1)>을 넣으면 innerHTML과 res.send()는 실제 <img> 요소를 만들고, 저장된 javascript:alert(1) 링크는 클릭 가능한 스크립트가 됩니다.

단계 3

브라우저가 실행

이미지 로딩이 실패해 onerror가 발생하거나 사용자가 링크를 클릭합니다. 공격자가 더 할 일 없이 코드가 즉시 실행됩니다.

단계 4

스크립트가 사이트로서 행동

코드는 페이지의 출처(origin)로 실행되어 DOM을 읽고, 피해자의 쿠키나 localStorage의 토큰으로 사이트 API를 호출하며, 사용자가 보는 내용을 바꿀 수 있습니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

취약한 구현
// profile.jsx: 아래 모든 함수가 문자열을 HTML로 파싱되게 한다
import express from 'express';

const app = express();

// Express: 전송 전에 쿼리 값이 HTML에 들어간다
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML은 name을 마크업으로 파싱한다
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React는 받은 HTML을 그대로 삽입한다
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// 링크를 클릭하면 javascript: URL이 실행된다
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
보안 강화 패치
// profile.jsx: 텍스트는 텍스트로 두고, HTML과 URL은 먼저 검사한다
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// 문자열만 받고, HTML 이스케이프한다
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent는 마크업을 절대 파싱하지 않는다
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// 삽입 전에 DOMPurify가 스크립트와 이벤트 핸들러를 제거한다
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// http와 https URL만 남기고 나머지는 #으로 바꾼다
function safeHref(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

export function Website({ url }) {
  return <a href={safeHref(url)}>Website</a>;
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처