jan-karel.com
Home / Security Measures / Web Security / XSS Prevention

XSS Prevention

XSS Prevention

XSS Prevention

No Script Carnival Today

Web risk is rarely mysterious. It usually lies in predictable mistakes that persist under time pressure.

For XSS Prevention the gains come from context-bound output, browser restrictions and secure frontend baselines.

This makes security less of a separate afterthought and more of a standard quality of your product.

Immediate measures (15 minutes)

Why this matters

The core of XSS Prevention is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.

Defense: how to do it right

The beauty of the web security industry is that we have had solutions for XSS for twenty years, and that we collectively refuse to use them. We know how it should be done. We have the tools. We have the standards. We have the documentation. And yet, year after year, XSS appears in the OWASP Top 10.

It is as if all of humanity has a manual for preventing fires, but we collectively keep tossing our cigarettes into the dry grass.

But well, for those who actually want to do their job, here are the defenses.

Output Encoding -- the first line of defense

The most fundamental defense against XSS is output encoding: rendering special characters harmless before they end up in the HTML.

<  wordt  &lt;
>  wordt  &gt;
"  wordt  &quot;
'  wordt  &#x27;
&  wordt  &amp;

Context-specific encoding is essential. HTML encoding protects in the HTML context but not in JavaScript strings. JavaScript encoding protects in JavaScript but not in HTML attributes. URL encoding protects in URLs but not in HTML.

# Python/Flask -- safe (Jinja2 auto-escapes)
{{ user_input }}

# UNSAFE -- | safe disables auto-escaping
{{ user_input | safe }}
// JavaScript -- UNSAFE
element.innerHTML = userInput;

// JavaScript -- safe
element.textContent = userInput;

The rule is simple: use textContent instead of innerHTML when rendering text. Use the auto-escaping of your template engine. And if you ever type | safe or {!! !!} or dangerouslySetInnerHTML, stop and ask yourself why.

Content Security Policy (CSP) -- the second line of defense

Content Security Policy is an HTTP header that tells the browser which sources a page is allowed to load. It is the most powerful defense against XSS, and at the same time the most underutilized.

Content-Security-Policy: default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';

With this header the page can only load scripts from its own domain ('self'). Inline scripts (<script>alert(1)</script>) are blocked. Event handler attributes (onerror=alert(1)) are blocked. eval() is blocked.

Important CSP directives for XSS prevention:

Directive Function
script-src Which sources may serve scripts
style-src Which sources may serve stylesheets
default-src Fallback for all resource types
frame-ancestors Who may load this page in an iframe
base-uri Which base URIs are allowed
form-action Where forms may submit to

Nonce-based CSP:

Content-Security-Policy: script-src 'nonce-R4nd0mStr1ng';
<!-- This script will execute (correct nonce) -->
<script nonce="R4nd0mStr1ng">
  // Legitimate application code
</script>

<!-- This script will be blocked (no nonce) -->
<script>alert('XSS')</script>

With nonce-based CSP all scripts must have a unique, per-request generated nonce. An attacker who injects XSS does not know the nonce and therefore cannot execute scripts.

Strict-dynamic:

Content-Security-Policy: script-src 'strict-dynamic' 'nonce-R4nd0m';

With strict-dynamic, scripts loaded by a trusted script (with the correct nonce) are also allowed to load scripts themselves. This makes it easier to implement CSP in complex applications with many dynamically loaded scripts.

HttpOnly Cookies

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

The HttpOnly flag prevents JavaScript from reading the cookie via document.cookie. This specifically protects against the most direct form of XSS exploitation: cookie theft.

But -- and this is important -- HttpOnly does not protect against:

  • CSRF via XSS (the browser sends cookies automatically)
  • Keylogging
  • DOM manipulation
  • Phishing via page replacement
  • localStorage theft

HttpOnly is a lock on one of the doors. Better than nothing, but not a complete solution.

DOMPurify -- sanitization for the client side

// UNSAFE
element.innerHTML = userInput;

// SAFE with DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);

DOMPurify is a JavaScript library that sanitizes HTML by removing all potentially dangerous elements and attributes. It is the industry standard for client-side HTML sanitization.

// DOMPurify configuration examples
DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href']
});

Specific defenses per XSS type

XSS Type Primary defense Secondary defense
Reflected Output encoding CSP, input validation
Stored Output encoding CSP, input sanitization
DOM-based Use textContent DOMPurify, CSP

For DOM-based XSS specifically:

  • Avoid innerHTML, document.write(), eval()
  • Use textContent or setAttribute() instead of innerHTML
  • Validate the origin for postMessage events:
window.addEventListener('message', function(event) {
  // ALWAYS check the origin!
  if (event.origin !== 'https://vertrouwd-domein.nl') {
    return; // Ignore messages from unknown sources
  }
  // Use textContent, NOT innerHTML
  document.getElementById('notificatie').textContent = event.data;
});

The cynical truth about "it's just JavaScript"

There is a certain type of manager in the tech industry -- you know them, those people with their polo shirts and their "agile" buzzwords -- who, at every pentest report containing an XSS finding, say: "But it's just a pop-up. How bad can it be?"

It's just a pop-up. Exactly. And a nuclear reactor is just a water boiler. And a gun is just a tube with a spring. The oversimplification misses the point so spectacularly that it is almost admirable.

It's just JavaScript. That same JavaScript that:

  • Can take over your session
  • Can log your passwords
  • Can replace your screen with a phishing page
  • Can execute transactions on your behalf
  • Can exfiltrate your corporate data
  • Can install a persistent backdoor in your browser

"It's just JavaScript" is the security equivalent of "it's just a little water" while the dam is breaking.

The problem is not that managers do not understand what JavaScript can do. The problem is that they do not want to understand what JavaScript can do. Because if they did, they would also have to understand that their application has been vulnerable for years, that they have been making the wrong choices for years, and that fixing it costs money. And it is always easier to wave away the risk than to address it.

That is why we make those nice alert(1) pop-ups. Not because that is the only thing we can do, but because it is the simplest way to prove that code execution is possible. The real exploit comes after. And you demonstrate it in a controlled environment, with the right tooling, so that even the most cynical manager can no longer deny the risk.

Prototype Pollution defense

For prototype pollution, specific defensive measures apply:

1. Use Object.create(null) for dictionaries:

// UNSAFE -- inherits from Object.prototype
let config = {};

// SAFE -- no prototype
let config = Object.create(null);

2. Use Map instead of plain objects:

// UNSAFE
let cache = {};
cache[userInput] = value;

// SAFE
let cache = new Map();
cache.set(userInput, value);

3. Check with hasOwnProperty:

function safeMerge(target, source) {
  for (let key in source) {
    if (source.hasOwnProperty(key)) {
      if (key === '__proto__' || key === 'constructor') {
        continue; // Skip dangerous keys
      }
      target[key] = source[key];
    }
  }
  return target;
}

4. Update your dependencies:

  • lodash >= 4.6.2
  • Avoid deep-extend, hoistjs, and other known vulnerable libraries

5. Freeze the prototype:

Object.freeze(Object.prototype);

This is the nuclear option: nobody can add properties to Object.prototype anymore. It may break legitimate code that extends prototypes, but it completely eliminates prototype pollution.

Node.js Injection defense

1. Never use eval() on user input:

// NEVER DO THIS
app.get('/calc', (req, res) => {
  let result = eval(req.query.expr);  // RCE!
  res.send(String(result));
});

// SAFE alternative -- use a parser
const mathjs = require('mathjs');
app.get('/calc', (req, res) => {
  let result = mathjs.evaluate(req.query.expr);
  res.send(String(result));
});

2. JSON.parse() is safe, eval() on JSON is not:

// SAFE
let data = JSON.parse(userInput);

// UNSAFE
let data = eval('(' + userInput + ')');

3. Use the --frozen-intrinsics flag:

node --frozen-intrinsics app.js

This freezes all built-in objects, making prototype pollution impossible.

4. Avoid vm as a sandbox:

The vm module of Node.js is not a security sandbox. Use vm2 or isolated-vm if you truly need to execute code in a sandbox, but be aware that these libraries can also have vulnerabilities.

Summary: the XSS toolbox

In this chapter we have journeyed from the simplest <script>alert(1) </script> to a fully operational XSS command and control system. Let us line up the most important lessons.

XSS Types

Type Payload location Server involved Requires click
Reflected URL parameters Yes Yes
Stored Database/file Yes No
DOM-based URL fragment/DOM No Sometimes

Defense overview

Measure Protects against Effectiveness
Output encoding Reflected, Stored XSS High
CSP (nonce-based) All XSS types Very high
HttpOnly cookies Cookie theft via XSS Medium
DOMPurify DOM-based XSS High
textContent DOM-based XSS High
Object.create(null) Prototype pollution High
JSON.parse Node.js injection (vs eval) High

Further reading and references

Source Topic
OWASP XSS Prevention Cheat Sheet Output encoding per context
OWASP Testing Guide - XSS Testing methodology
PortSwigger Web Security Academy - XSS Interactive labs
Google CSP Evaluator CSP policy testing
DOMPurify GitHub repository Client-side sanitization
CWE-79: Improper Neutralization of Input Formal vulnerability definition
CWE-1321: Improperly Controlled Modification Prototype pollution CWE
NodeGoat OWASP Project Node.js security labs
HackTricks - XSS Extensive payload collection
Browser security test suite Browser security validation

The next time someone tells you that XSS is "just a pop-up," invite them to a demonstration in a controlled lab environment. Nothing convinces as quickly as seeing your own keystrokes on someone else's screen.

In the next chapter we dive into Server-Side Request Forgery (SSRF) -- a vulnerability where we use the server itself as a proxy to reach places we should not be. If XSS is about manipulating the browser, SSRF is about manipulating the server. And servers typically have access to far more interesting things than browsers.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Web Security ← Home