Introduction

Welcome back, future security champion! In previous chapters, we laid the groundwork for understanding the attacker’s mindset and the importance of security. Now, we’re diving into one of the most common and impactful web vulnerabilities: Cross-Site Scripting, or XSS. It’s so prevalent it consistently ranks high on the OWASP Top 10 list (currently A03:2021-Injection).

This chapter will demystify XSS. We’ll explore its different flavors – Stored, Reflected, and DOM-based – understanding how each works internally and how attackers exploit them. More importantly, we’ll equip you with the knowledge and practical skills to safely reproduce these vulnerabilities in a controlled environment and, crucial for a developer, implement effective prevention mechanisms. Get ready to write some secure code and protect your users!

To get the most out of this chapter, you should be comfortable with basic HTML, CSS, JavaScript, and have a general understanding of how web applications send and receive data between the client (browser) and the server.

Core Concepts: Understanding XSS

At its heart, Cross-Site Scripting (XSS) is a type of injection attack where an attacker injects malicious client-side scripts (typically JavaScript) into web pages viewed by other users. Think of it like this: a website is supposed to show you content. With XSS, an attacker tricks the website into showing their malicious content, specifically JavaScript, which then runs in your browser, under the context of that legitimate website.

Why is this dangerous? Because that script can do almost anything a legitimate script on the page can do:

  • Steal session cookies, allowing the attacker to hijack your logged-in session.
  • Deface the website or inject phishing forms.
  • Redirect you to malicious websites.
  • Perform actions on your behalf (e.g., transfer funds, post messages) if the site uses JavaScript to make requests.
  • Access sensitive information displayed on the page.

The key takeaway for developers? Never trust user input. Any data that comes from a user, an external API, or even your own database (if it originated from user input) must be treated with suspicion and handled securely before being rendered in the browser.

Let’s break down the three main types of XSS.

Reflected XSS (Non-Persistent)

Reflected XSS, sometimes called Non-Persistent XSS, occurs when a malicious script is “reflected” off a web server and immediately executed in the user’s browser. The payload originates from the victim’s request and is echoed back in the server’s response. It’s non-persistent because the malicious script is not stored on the server; it only exists in the specific crafted URL used for the attack.

How it works:

  1. An attacker crafts a malicious URL containing an XSS payload.
  2. The attacker sends this URL to a victim (e.g., via email, chat, or a malicious link on another site).
  3. The victim clicks the link.
  4. The victim’s browser sends a request to the vulnerable web application.
  5. The web application processes the request, takes the malicious script from the URL, and includes it directly in its HTTP response without proper sanitization or encoding.
  6. The victim’s browser receives the response and executes the malicious script because it’s part of the legitimate page content.

Here’s a simplified flow:

flowchart LR A[Attacker] -->|Sends malicious URL| B[Victim] B -->|Clicks URL, sends request with payload| C[Vulnerable Web Server] C -->|Reflects payload in response| B B -->|Browser executes script| D[Malicious Action]

Example Scenario: Imagine a search page that displays your search query directly on the page: https://example.com/search?query=hello. An attacker might craft: https://example.com/search?query=<script>alert('You are hacked!');</script> If the application doesn’t properly handle the query parameter, the alert script will execute in the victim’s browser.

Stored XSS (Persistent)

Stored XSS, also known as Persistent XSS, is generally considered more dangerous because the malicious script is permanently stored on the target server (e.g., in a database, forum post, comment section, or visitor log). When any user later visits the page that displays this stored information, the malicious script is retrieved from the server and executed in their browser.

How it works:

  1. An attacker submits a malicious script as part of legitimate user input (e.g., a comment, a user profile update, a forum post) to the vulnerable web application.
  2. The web application stores this malicious script on its server (e.g., in a database).
  3. Later, a legitimate victim browses to the page where this stored content is displayed.
  4. The web application retrieves the malicious script from its storage and includes it in the HTTP response sent to the victim.
  5. The victim’s browser executes the malicious script.

Here’s a simplified flow:

flowchart LR A["Attacker"] -->|"Submits malicious input (e.g., comment)"| B["Vulnerable Web Server"] B -->|"Stores payload in Database"| C["Database"] C -->|"Serves payload to legitimate user"| B B -->|"Sends response with payload"| D["Legitimate Victim"] D -->|"Browser executes script"| E["Malicious Action"]

Example Scenario: A comment section on a blog. An attacker posts a comment like: <script>fetch('/api/steal-cookie', {method: 'POST', body: document.cookie});</script>. Any user who views that blog post and its comments will have their cookies sent to the attacker’s server.

DOM-based XSS

DOM-based XSS is a client-side vulnerability where the attack payload is executed due to the modification of the Document Object Model (DOM) environment in the victim’s browser. Unlike Reflected or Stored XSS, the server-side code might not be involved in the vulnerability itself. Instead, the vulnerability arises from client-side JavaScript that takes user-controlled data (e.g., from location.hash, document.URL, localStorage, sessionStorage, or query parameters) and writes it into the DOM without proper sanitization.

How it works:

  1. An attacker crafts a malicious URL (similar to Reflected XSS) and sends it to a victim.
  2. The victim clicks the link.
  3. The victim’s browser loads the legitimate page.
  4. A client-side JavaScript on that page reads data from the URL (e.g., window.location.hash) or another untrusted source.
  5. The script then directly injects this unsanitized data into the page’s DOM (e.g., using innerHTML, document.write).
  6. The browser renders the modified DOM, executing the malicious script.

Here’s a simplified flow:

flowchart LR A[Attacker] -->|Sends malicious URL with #payload| B[Victim] B -->|Clicks URL, browser loads page| C[Legitimate Web Page] C -->|JS reads #payload from URL| C C -->|JS writes unsanitized payload to DOM| C C -->|Browser executes script| D[Malicious Action]

Example Scenario: A page uses JavaScript to personalize a greeting based on a URL fragment: index.html contains:

document.getElementById('greeting').innerHTML = 'Hello, ' + window.location.hash.substring(1) + '!';

An attacker could send: index.html#<script>alert('DOM XSS!');</script> The script would be injected into the greeting element and execute.

Step-by-Step Implementation: Demonstrating and Preventing XSS

Let’s get hands-on! We’ll set up a minimal web application using Node.js and Express to demonstrate each XSS type and then implement the necessary preventions.

Prerequisites: Make sure you have Node.js installed. As of January 2026, Node.js LTS version 20.x is recommended for stability. You can download it from the official Node.js website. Node.js official website

Project Setup:

  1. Create a new directory for our project:
    mkdir xss-demo && cd xss-demo
    
  2. Initialize a new Node.js project:
    npm init -y
    
  3. Install Express, our web framework:
    npm install [email protected]
    
    (Using 4.18.2 which is the latest stable version of Express 4.x as of 2026-01-04).
  4. Create a file named app.js in your project root.

1. Reflected XSS Demo

We’ll create a simple search page that reflects a user’s query.

app.js (Vulnerable Reflected XSS):

// app.js
const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
    res.send(`
        <h1>Search Page</h1>
        <form action="/search" method="GET">
            <label for="query">Search:</label>
            <input type="text" id="query" name="query">
            <button type="submit">Submit</button>
        </form>
    `);
});

// This route is vulnerable to Reflected XSS
app.get('/search', (req, res) => {
    const searchQuery = req.query.query; // Directly taking user input
    res.send(`
        <h1>Search Results</h1>
        <p>You searched for: ${searchQuery}</p> 
        <a href="/">Go back</a>
    `);
});

app.listen(port, () => {
    console.log(`Vulnerable app listening at http://localhost:${port}`);
});

Explanation:

  • We create two routes: / for the search form and /search for displaying results.
  • In the /search route, req.query.query directly takes the user’s input from the URL query parameter.
  • Crucially, ${searchQuery} is inserted directly into the HTML response without any sanitization or encoding. This is the vulnerability!

How to Exploit (Reflected XSS):

  1. Start the server:
    node app.js
    
  2. Open your browser and navigate to http://localhost:3000.
  3. In the search bar, type a malicious script: <script>alert('Reflected XSS!');</script>
  4. Click “Submit”.

You should see an alert box pop up with “Reflected XSS!”. This demonstrates that your injected script executed in the browser.

Prevention for Reflected XSS: Output Encoding

The primary defense against Reflected XSS (and Stored XSS) is output encoding. This means converting characters that have special meaning in an HTML context (like <, >, ", ', &) into their HTML entity equivalents (e.g., < becomes &lt;). This way, the browser interprets them as literal text, not as part of the HTML structure or executable code.

app.js (Prevented Reflected XSS):

First, install a simple HTML escaping library:

npm install [email protected]

(Using 1.0.3 which is the latest stable version as of 2026-01-04).

Now, modify app.js:

// app.js (Prevented Reflected XSS)
const express = require('express');
const escapeHtml = require('escape-html'); // Import the escape-html library
const app = express();
const port = 3000;

app.get('/', (req, res) => {
    res.send(`
        <h1>Search Page</h1>
        <form action="/search-safe" method="GET">
            <label for="query">Search:</label>
            <input type="text" id="query" name="query">
            <button type="submit">Submit</button>
        </form>
    `);
});

// This route now uses output encoding to prevent Reflected XSS
app.get('/search-safe', (req, res) => {
    const searchQuery = req.query.query;
    // IMPORTANT: Encode the user input before rendering it in HTML
    const safeSearchQuery = escapeHtml(searchQuery || ''); // Handle undefined query gracefully
    res.send(`
        <h1>Search Results (Safe)</h1>
        <p>You safely searched for: ${safeSearchQuery}</p> 
        <a href="/">Go back</a>
    `);
});

app.listen(port, () => {
    console.log(`Safe app listening at http://localhost:${port}`);
});

Explanation:

  • We’ve added a new route /search-safe.
  • Before inserting searchQuery into the HTML, we pass it through escapeHtml().
  • Now, if you try to inject <script>alert('Reflected XSS!');</script>, it will be rendered as &lt;script&gt;alert('Reflected XSS!');&lt;/script&gt;, appearing as plain text on the page without executing.

2. Stored XSS Demo

Let’s build a simple guestbook where users can leave messages. For this demo, we’ll store messages in memory (a simple array). In a real application, this would be a database.

app.js (Vulnerable Stored XSS):

// app.js (Vulnerable Stored XSS)
const express = require('express');
const bodyParser = require('body-parser'); // To parse POST request bodies
const app = express();
const port = 3000;

// In-memory storage for guestbook entries (for demo purposes)
const guestbookEntries = [];

// Middleware to parse URL-encoded bodies (for form submissions)
app.use(bodyParser.urlencoded({ extended: true }));

app.get('/guestbook', (req, res) => {
    let entriesHtml = guestbookEntries.map(entry => `
        <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;">
            <p><strong>Message:</strong> ${entry.message}</p>
            <small>Posted at: ${entry.timestamp}</small>
        </div>
    `).join('');

    res.send(`
        <h1>Guestbook</h1>
        <form action="/guestbook" method="POST">
            <label for="message">Your Message:</label><br>
            <textarea id="message" name="message" rows="4" cols="50"></textarea><br>
            <button type="submit">Post Message</button>
        </form>
        <hr>
        <h2>Recent Entries:</h2>
        ${entriesHtml} <!-- VULNERABLE: Direct injection of stored user input -->
    `);
});

app.post('/guestbook', (req, res) => {
    const userMessage = req.body.message; // Directly taking user input
    if (userMessage) {
        guestbookEntries.push({
            message: userMessage,
            timestamp: new Date().toLocaleString()
        });
    }
    res.redirect('/guestbook'); // Redirect back to view entries
});

app.listen(port, () => {
    console.log(`Vulnerable app listening at http://localhost:${port}`);
});

Explanation:

  • We use body-parser to handle POST requests.
  • guestbookEntries array stores messages.
  • The /guestbook GET route displays a form and all current entries.
  • The /guestbook POST route takes a message, stores it, and redirects.
  • The vulnerability is in ${entriesHtml} where entry.message (which came from user input) is directly embedded into the HTML without encoding.

How to Exploit (Stored XSS):

  1. Start the server:
    node app.js
    
  2. Open your browser to http://localhost:3000/guestbook.
  3. In the message box, type: <script>alert('Stored XSS!');</script>
  4. Click “Post Message”.
  5. You should see an alert pop up. Now, every time you (or any other user) visits http://localhost:3000/guestbook, that alert will pop up because the malicious script is stored and served with the page.

Prevention for Stored XSS: Output Encoding & Content Security Policy (CSP)

Prevention for Stored XSS is similar to Reflected XSS: output encoding is paramount. Additionally, a Content Security Policy (CSP) provides a strong defense-in-depth layer.

app.js (Prevented Stored XSS):

// app.js (Prevented Stored XSS)
const express = require('express');
const bodyParser = require('body-parser');
const escapeHtml = require('escape-html'); // For output encoding
const app = express();
const port = 3000;

const guestbookEntries = [];
app.use(bodyParser.urlencoded({ extended: true }));

// --- IMPORTANT: Adding Content Security Policy (CSP) header ---
// As of 2026, CSP is a critical defense-in-depth mechanism.
// This CSP allows scripts only from the same origin ('self') and blocks inline scripts.
// For more complex applications, you might need to add specific domains for scripts,
// or use 'nonce' or 'hash' for legitimate inline scripts.
// Refer to the official MDN Web Docs for comprehensive CSP guidelines:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
app.use((req, res, next) => {
    res.setHeader(
        'Content-Security-Policy',
        "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
        // 'unsafe-inline' for style-src is often acceptable for basic demos, but for scripts it's a huge NO.
        // For production, scrutinize every 'unsafe-inline' and aim to remove it.
    );
    next();
});

app.get('/guestbook-safe', (req, res) => {
    let entriesHtml = guestbookEntries.map(entry => {
        // IMPORTANT: Output encode the message before rendering
        const safeMessage = escapeHtml(entry.message || '');
        return `
            <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;">
                <p><strong>Message:</strong> ${safeMessage}</p>
                <small>Posted at: ${entry.timestamp}</small>
            </div>
        `;
    }).join('');

    res.send(`
        <h1>Guestbook (Safe)</h1>
        <form action="/guestbook-safe" method="POST">
            <label for="message">Your Message:</label><br>
            <textarea id="message" name="message" rows="4" cols="50"></textarea><br>
            <button type="submit">Post Message</button>
        </form>
        <hr>
        <h2>Recent Entries:</h2>
        ${entriesHtml}
    `);
});

app.post('/guestbook-safe', (req, res) => {
    // Input validation (optional but good practice for any input)
    const userMessage = req.body.message ? String(req.body.message).trim() : '';
    if (userMessage) {
        guestbookEntries.push({
            message: userMessage,
            timestamp: new Date().toLocaleString()
        });
    }
    res.redirect('/guestbook-safe');
});

app.listen(port, () => {
    console.log(`Safe app listening at http://localhost:${port}`);
});

Explanation:

  1. Output Encoding: Just like with Reflected XSS, we use escapeHtml() on entry.message before rendering it. This ensures any HTML special characters are converted to entities.
  2. Content Security Policy (CSP): We’ve added a middleware that sets the Content-Security-Policy HTTP header.
    • default-src 'self' means resources (images, scripts, styles, etc.) can only be loaded from the same origin as the document.
    • script-src 'self' specifically restricts JavaScript sources to the same origin. This is crucial because it blocks inline scripts (like <script>alert()</script>) and scripts from untrusted external domains. Even if an attacker manages to inject a script, the browser’s CSP might prevent it from executing.
    • style-src 'self' 'unsafe-inline' allows inline styles, which is common for simple apps but should be reviewed for production. For scripts, 'unsafe-inline' is almost always a bad idea.

How to Test Prevention (Stored XSS):

  1. Restart the server with the updated app.js.
  2. Navigate to http://localhost:3000/guestbook-safe.
  3. Try posting <script>alert('Stored XSS!');</script> again.
  4. You should now see the script rendered as plain text, and no alert box will appear. If you inspect your browser’s console, you might see CSP violation warnings.

3. DOM-based XSS Demo

For DOM-based XSS, the vulnerability is purely client-side. We’ll create a simple HTML file that reads from the URL fragment (#) and injects it into the page.

public/index.html (Vulnerable DOM-based XSS):

First, create a public directory and an index.html inside it.

mkdir public

public/index.html:

<!-- public/index.html (Vulnerable DOM-based XSS) -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>DOM XSS Demo</title>
</head>
<body>
    <h1>Welcome!</h1>
    <div id="greeting"></div>

    <script>
        // VULNERABLE: Directly using window.location.hash without sanitization
        const userGreeting = window.location.hash.substring(1); 
        // If hash is #<script>alert('DOM XSS!');</script>, this will inject it directly
        document.getElementById('greeting').innerHTML = 'Hello, ' + userGreeting + '!';
    </script>
</body>
</html>

app.js (to serve static files):

Modify app.js to serve this static HTML file.

// app.js (Serving static HTML for DOM XSS demo)
const express = require('express');
const path = require('path'); // Node.js built-in path module
const app = express();
const port = 3000;

// Serve static files from the 'public' directory
app.use(express.static(path.join(__dirname, 'public')));

app.listen(port, () => {
    console.log(`DOM XSS demo app listening at http://localhost:${port}`);
    console.log(`Visit http://localhost:${port}/index.html to test.`);
});

Explanation:

  • express.static() middleware serves files from the public directory.
  • In index.html, the JavaScript directly takes window.location.hash.substring(1) (everything after the # in the URL) and uses it with innerHTML. innerHTML is dangerous when dealing with untrusted input because it parses and renders HTML.

How to Exploit (DOM-based XSS):

  1. Start the server:
    node app.js
    
  2. Open your browser and navigate to: http://localhost:3000/index.html#<script>alert('DOM XSS!');</script>
  3. You should see an alert box pop up. The script in the URL fragment was executed by the client-side JavaScript.

Prevention for DOM-based XSS: Client-side Sanitization & Safe DOM Manipulation

For DOM-based XSS, the responsibility lies primarily with the client-side JavaScript.

  1. Use textContent instead of innerHTML when you only intend to display plain text. textContent automatically escapes HTML characters, making it safe.
  2. Sanitize input before using innerHTML: If you must insert HTML, use a dedicated client-side HTML sanitization library like DOMPurify.

public/index-safe.html (Prevented DOM-based XSS):

First, let’s create a new safe HTML file. For DOMPurify, you’d typically include it via a CDN or npm. For simplicity, we’ll use a CDN link for this demo.

<!-- public/index-safe.html (Prevented DOM-based XSS) -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>DOM XSS Demo (Safe)</title>
    <!-- Include DOMPurify from CDN. Latest stable as of 2026-01-04 is v3.0.6 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
</head>
<body>
    <h1>Welcome (Safe)!</h1>
    <div id="greeting-safe"></div>

    <script>
        const userGreeting = window.location.hash.substring(1); 
        const greetingElement = document.getElementById('greeting-safe');

        // --- IMPORTANT: Safely inserting user input ---

        // Option 1: If you ONLY expect plain text, use textContent
        // greetingElement.textContent = 'Hello, ' + userGreeting + '!';

        // Option 2: If you expect rich HTML but need to sanitize it, use DOMPurify
        // DOMPurify.sanitize() removes malicious code while preserving safe HTML.
        const sanitizedGreeting = DOMPurify.sanitize('Hello, ' + userGreeting + '!');
        greetingElement.innerHTML = sanitizedGreeting; // Now safe to use innerHTML

    </script>
</body>
</html>

Explanation:

  • We’ve added a CDN link for DOMPurify.
  • Instead of directly assigning to innerHTML, we first pass userGreeting (or the full string containing it) through DOMPurify.sanitize(). This function meticulously cleans the HTML string, removing any potentially malicious scripts or attributes, making it safe for innerHTML.
  • If you only ever expect plain text, using textContent is even simpler and guarantees safety.

How to Test Prevention (DOM-based XSS):

  1. Ensure your app.js is still serving static files.
  2. Open your browser and navigate to: http://localhost:3000/index-safe.html#<script>alert('DOM XSS!');</script>
  3. You should now see “Hello, <script>alert(‘DOM XSS!’);</script>!” as plain text, and no alert box will appear. The script has been neutralized by DOMPurify.

Mini-Challenge

Challenge: Modify the vulnerable Reflected XSS demo (/search route in app.js) to prevent the XSS, but without using the escape-html library. Instead, implement your own simple HTML encoding function. Then, ensure the CSP header is also applied to this route for an extra layer of defense.

Hint: Your HTML encoding function should replace at least <, >, ", ', and & characters with their corresponding HTML entities. Remember that the res.setHeader() method for CSP can be applied as a middleware for all routes or specifically within a route handler.

What to Observe/Learn:

  • How manual HTML encoding works.
  • The combined power of output encoding and CSP.
  • The importance of handling all relevant special characters.

Common Pitfalls & Troubleshooting

  1. Forgetting Server-Side Encoding/Sanitization: A common mistake is to rely solely on client-side validation or sanitization. Attackers can bypass client-side checks easily. Always perform output encoding and input validation on the server.
  2. Incomplete Encoding: Only encoding a few characters (e.g., just < and >) isn’t enough. Many other characters (like " and ' for attributes, or & for entities) can be used in XSS payloads. Use comprehensive libraries or functions.
  3. Overly Permissive CSP: A CSP that allows 'unsafe-inline' or 'unsafe-eval' for script-src effectively negates much of its protection against XSS. Be very specific about your allowed script sources.
  4. Using innerHTML Unsafely: Directly assigning user-controlled input to element.innerHTML without sanitization is a prime source of DOM-based XSS. Prefer textContent or robust sanitization libraries like DOMPurify.
  5. Confusing Input Validation with Output Encoding:
    • Input Validation: Checks if input conforms to expected format/type (e.g., “is this an email address?”). It rejects invalid input.
    • Output Encoding: Transforms valid input characters so they are safely displayed in a specific context (e.g., HTML, URL). It modifies input. Both are crucial, but serve different purposes. You validate when you receive input, and encode when you output it.

Summary

You’ve just tackled one of the “big ones” in web security: Cross-Site Scripting! Here are the key takeaways:

  • XSS is an injection attack where malicious client-side scripts are injected into web pages, running in the victim’s browser context.
  • There are three main types:
    • Reflected XSS: Malicious script is echoed back immediately from server response.
    • Stored XSS: Malicious script is permanently stored on the server and served to multiple users.
    • DOM-based XSS: Client-side JavaScript modifies the DOM unsafely with user-controlled data.
  • Never trust user input. All input must be treated as potentially malicious.
  • Primary Prevention: Output Encoding (e.g., converting < to &lt;) is essential for any user-supplied data rendered into HTML. Libraries like escape-html or templating engine’s auto-escaping help.
  • Defense-in-Depth:
    • Content Security Policy (CSP): A powerful HTTP header that restricts what resources a browser can load and execute, significantly reducing the impact of XSS.
    • Input Validation & Sanitization: While not a direct XSS prevention (encoding is), validating input format and sanitizing potentially harmful elements (e.g., removing script tags if you allow rich text) is a good practice.
    • Safe DOM Manipulation: On the client-side, use textContent for plain text and DOMPurify.sanitize() before using innerHTML when displaying user-controlled content.
  • Modern frameworks like React and Angular often auto-escape data when rendered as text, but developers must be vigilant when using features like dangerouslySetInnerHTML or [innerHTML].

Understanding XSS is fundamental to building secure web applications. By consistently applying output encoding and leveraging CSP, you’re well on your way to protecting your users from these common attacks. In the next chapter, we’ll explore another critical vulnerability: Cross-Site Request Forgery (CSRF).

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.