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:
- An attacker crafts a malicious URL containing an XSS payload.
- The attacker sends this URL to a victim (e.g., via email, chat, or a malicious link on another site).
- The victim clicks the link.
- The victim’s browser sends a request to the vulnerable web application.
- 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.
- 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:
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:
- 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.
- The web application stores this malicious script on its server (e.g., in a database).
- Later, a legitimate victim browses to the page where this stored content is displayed.
- The web application retrieves the malicious script from its storage and includes it in the HTTP response sent to the victim.
- The victim’s browser executes the malicious script.
Here’s a simplified flow:
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:
- An attacker crafts a malicious URL (similar to Reflected XSS) and sends it to a victim.
- The victim clicks the link.
- The victim’s browser loads the legitimate page.
- A client-side JavaScript on that page reads data from the URL (e.g.,
window.location.hash) or another untrusted source. - The script then directly injects this unsanitized data into the page’s DOM (e.g., using
innerHTML,document.write). - The browser renders the modified DOM, executing the malicious script.
Here’s a simplified flow:
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:
- Create a new directory for our project:
mkdir xss-demo && cd xss-demo - Initialize a new Node.js project:
npm init -y - Install Express, our web framework:(Using
npm install express@4.18.24.18.2which is the latest stable version of Express 4.x as of 2026-01-04). - Create a file named
app.jsin 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/searchfor displaying results. - In the
/searchroute,req.query.querydirectly 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):
- Start the server:
node app.js - Open your browser and navigate to
http://localhost:3000. - In the search bar, type a malicious script:
<script>alert('Reflected XSS!');</script> - 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 <). 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 escape-html@1.0.3
(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
searchQueryinto the HTML, we pass it throughescapeHtml(). - Now, if you try to inject
<script>alert('Reflected XSS!');</script>, it will be rendered as<script>alert('Reflected XSS!');</script>, 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-parserto handle POST requests. guestbookEntriesarray stores messages.- The
/guestbookGET route displays a form and all current entries. - The
/guestbookPOST route takes a message, stores it, and redirects. - The vulnerability is in
${entriesHtml}whereentry.message(which came from user input) is directly embedded into the HTML without encoding.
How to Exploit (Stored XSS):
- Start the server:
node app.js - Open your browser to
http://localhost:3000/guestbook. - In the message box, type:
<script>alert('Stored XSS!');</script> - Click “Post Message”.
- 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:
- Output Encoding: Just like with Reflected XSS, we use
escapeHtml()onentry.messagebefore rendering it. This ensures any HTML special characters are converted to entities. - Content Security Policy (CSP): We’ve added a middleware that sets the
Content-Security-PolicyHTTP 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):
- Restart the server with the updated
app.js. - Navigate to
http://localhost:3000/guestbook-safe. - Try posting
<script>alert('Stored XSS!');</script>again. - 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 thepublicdirectory.- In
index.html, the JavaScript directly takeswindow.location.hash.substring(1)(everything after the#in the URL) and uses it withinnerHTML.innerHTMLis dangerous when dealing with untrusted input because it parses and renders HTML.
How to Exploit (DOM-based XSS):
- Start the server:
node app.js - Open your browser and navigate to:
http://localhost:3000/index.html#<script>alert('DOM XSS!');</script> - 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.
- Use
textContentinstead ofinnerHTMLwhen you only intend to display plain text.textContentautomatically escapes HTML characters, making it safe. - Sanitize input before using
innerHTML: If you must insert HTML, use a dedicated client-side HTML sanitization library likeDOMPurify.
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 passuserGreeting(or the full string containing it) throughDOMPurify.sanitize(). This function meticulously cleans the HTML string, removing any potentially malicious scripts or attributes, making it safe forinnerHTML. - If you only ever expect plain text, using
textContentis even simpler and guarantees safety.
How to Test Prevention (DOM-based XSS):
- Ensure your
app.jsis still serving static files. - Open your browser and navigate to:
http://localhost:3000/index-safe.html#<script>alert('DOM XSS!');</script> - 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
- 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.
- 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. - Overly Permissive CSP: A CSP that allows
'unsafe-inline'or'unsafe-eval'forscript-srceffectively negates much of its protection against XSS. Be very specific about your allowed script sources. - Using
innerHTMLUnsafely: Directly assigning user-controlled input toelement.innerHTMLwithout sanitization is a prime source of DOM-based XSS. PrefertextContentor robust sanitization libraries likeDOMPurify. - 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<) is essential for any user-supplied data rendered into HTML. Libraries likeescape-htmlor 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
scripttags if you allow rich text) is a good practice. - Safe DOM Manipulation: On the client-side, use
textContentfor plain text andDOMPurify.sanitize()before usinginnerHTMLwhen 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
dangerouslySetInnerHTMLor[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
- OWASP Top 10 2021: https://owasp.org/www-project-top-ten/
- MDN Web Docs - Content Security Policy (CSP): https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
- OWASP Cheat Sheet Series - XSS Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- DOMPurify GitHub Repository: https://github.com/DOMPurify/DOMPurify
- Node.js Official Website: https://nodejs.org/
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.