9 min read

Intermediate

XSS Remediation

Risk Severity
High
Fix Effort
Medium
Est. Time
1-3 hours
Reference
A05:2025 CWE-79

Cross-Site Scripting (XSS) flaws occur whenever an application includes untrusted data in a new web page without proper validation or escaping. This allows attackers to execute malicious scripts in the victim's browser.

XSS Impact

XSS allows attackers to hijack user sessions, steal credentials, redirect users to malicious sites, deface websites, and perform actions as the victim. Stored XSS is particularly dangerous as it affects all users who view the compromised content.

XSS Attack & Defense Flow

📑 Table of Contents

⚠️ Pitfalls to Avoid

Stored XSS

Malicious script is permanently stored on the target server (database, comments, profiles). Affects all users who view the data.

Reflected XSS

Script is reflected off the server (error messages, search results). Requires victim to click a crafted link.

DOM-based XSS

Vulnerability exists in client-side JavaScript. Payload never reaches the server. Harder to detect with server-side tools.

Output Encoding

The primary defense against XSS is context-sensitive output encoding. Convert untrusted input into a safe form where the input is displayed as data to the user without executing as code in the browser.

📍 Context Matters

Different contexts require different encoding. HTML encoding (< → &lt;) is correct for HTML body content but wrong for JavaScript strings or URLs. Using the wrong encoding can leave you vulnerable or break functionality.

Python (Flask/Jinja2)

Auto-escaping is enabled by default. In templates, use {{ variable }} which is automatically escaped. Only use {{ variable|safe }} when you've sanitized content.

For manual encoding:

from markupsafe import escape
safe_output = escape(user_input)

JavaScript (DOM Manipulation)

Vulnerable:

element.innerHTML = userInput;
document.write(userInput);

Secure (Text Content):

element.textContent = userInput;

Secure (HTML Content with DOMPurify):

const clean = DOMPurify.sanitize(userInput);
element.innerHTML = clean;

React

JSX auto-escapes by default:

return <div>{userInput}</div>;

Dangerous (Avoid):

return <div dangerouslySetInnerHTML={{__html: userInput}} />;

PHP

HTML Context:

echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

JavaScript Context:

echo json_encode($userInput, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

URL Context:

echo urlencode($userInput);

Node.js (Express with EJS)

Use <%= %> for escaped output (safe). Avoid <%- %>.

<p><%= userInput %></p>

Content Security Policy (CSP)

CSP is a defense-in-depth mechanism that can significantly reduce the impact of XSS attacks by restricting the sources of executable scripts.

🛡️ How CSP Works

CSP tells the browser exactly which resources are allowed to load and execute. Even if an attacker injects <script> tags, the browser will refuse to execute them if they violate the policy. It's like a firewall for your page's content.

Strong CSP Header

This policy allows scripts only from your own domain, blocks inline scripts (the primary XSS vector), and prevents your page from being framed.

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';
    base-uri 'self';
    form-action 'self';

Express.js (Helmet)

const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
    directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'"],
        frameAncestors: ["'none'"],
    }
}));

Flask (Talisman)

from flask_talisman import Talisman

csp = {
    'default-src': "'self'",
    'script-src': "'self'",
    'style-src': "'self' 'unsafe-inline'",
}
Talisman(app, content_security_policy=csp)

Django (django-csp)

# In settings.py
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")

Web Server Config

Apache (.htaccess):

Header set Content-Security-Policy "default-src 'self'; script-src 'self'"

Nginx:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'";

Testing Verification

Use these payloads to verify your XSS protections are working. Test in all input fields and URL parameters.

XSS Test Payloads

# Basic XSS tests - should be encoded, not execute
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>

# Attribute injection tests
" onmouseover="alert('XSS')
' onclick='alert(1)'

# JavaScript URL scheme
javascript:alert('XSS')

# Template literal injection (for JS contexts)
${alert('XSS')}

# Expected result: All payloads should display as text, NOT execute

CSP Validation

# Check CSP header is present
curl -I https://yoursite.com | grep -i "content-security-policy"

# Test inline script blocking (should fail with CSP)
# Add this to a page - it should NOT execute with proper CSP
<script>console.log('inline script')</script>

# Browser DevTools: Check Console for CSP violation reports
# Look for: "Refused to execute inline script..."

Common Mistakes

❌ innerHTML with User Data

// DON'T: Direct HTML assignment
element.innerHTML = userInput;
document.write(userInput);

Allows arbitrary HTML/script injection

✅ Correct Approach

// DO: Use textContent or sanitize
element.textContent = userInput;
// or with sanitizer
element.innerHTML = DOMPurify.sanitize(userInput);

textContent treats everything as text

❌ Blacklist-Based Filtering

# DON'T: Easy to bypass
def sanitize(input):
    return input.replace('<script>', '')

Easily bypassed with <SCRIPT> or <scr<script>ipt>

✅ Correct Approach

# DO: Use proper encoding
from markupsafe import escape
safe = escape(user_input)  # Encodes ALL special chars

Encoding is foolproof, filtering is not

❌ Weak CSP with unsafe-inline

Content-Security-Policy: 
  script-src 'self' 'unsafe-inline';

unsafe-inline defeats the purpose of CSP

✅ Correct Approach

Content-Security-Policy: 
  script-src 'self' 'nonce-abc123';

Use nonces or hashes for inline scripts

XSS Security Labs

Practice identifying and exploiting XSS to understand defenses.

XSS Contexts PortSwigger medium
Reflected XSSStored XSSDOM-based XSS
XSS Game Custom Lab easy
Script injectionAttribute injectionCSP bypass

Evidence to Capture

The write and the read: The request that stored the value and the separate request that renders it to the victim, so stored XSS is not confused with a reflection the tester caused. Include the role that sees the rendered page.

Execution proof: The DOM snapshot or console entry showing the handler fired, or the callback to your listener carrying a marker such as document.domain, plus the page URL where it fired.

Sink and context: The exact line that wrote the value — innerHTML, document.write, dangerouslySetInnerHTML, an unquoted attribute — and which of HTML, attribute, JS, URL or CSS it sits in.

Policy state: The Content-Security-Policy header on that response verbatim, and whether it reported a block. A payload that fires anyway proves a policy gap as well as the sink.

Cookie exposure: Whether the session cookie is HttpOnly, because that decides whether theft is possible or the impact is limited to actions in the victim session.

Likely False Positives

  • Payload reflected but encoded: < returns as &lt; in the same context and the DOM shows text, not an element. Encoded output is the fix, not the bug.
  • Self-XSS: The payload only runs because the tester pasted it into the console, a devtools preview, or a field that renders for the author alone. A victim must be able to reach the same execution without developer tools.
  • Reflection blocked by CSP: A scanner reports the reflection while the browser refuses to execute it. That is a hardening observation and belongs in a separate finding from XSS.
  • Framework auto-escaping: React, Jinja and Razor escape the rendered path by default. Show that the unescaped variant you found actually receives attacker input before claiming the sink is live.
  • Truncated or JSON-encoded response: A payload cut by a length limit or JSON-escaped is inert even though it appears in the body. Verify in the rendered DOM, not in the HTTP response text.

Testing Checklist

Discovery

  • ☐ Map every output path for a given input: server template, JSON consumed by the client, and the DOM sink that finally writes it.
  • ☐ Include sources that never reach the server — location.hash, document.referrer, postMessage and stored JSON.
  • ☐ Record the CSP and any library that sanitises or renders HTML, with its version, because both determine whether a payload can execute.

Confirmation

  • ☐ Send a harmless canary first ("><i data-x>) and inspect the DOM to learn the exact context.
  • ☐ Use a payload for that context only: HTML body, quoted or unquoted attribute, JS string, URL, or CSS.
  • ☐ For stored cases, confirm persistence after a fresh login and confirm the victim view renders it, then check whether an admin page renders it too.
  • ☐ Re-test after the fix with the original payload and confirm the DOM holds text rather than a node.

Impact

  • ☐ Demonstrate an action worth reporting — token or session exfiltration to your listener, or a privileged state change performed as the admin — rather than an alert box.
  • ☐ State whether the session cookie is HttpOnly and whether the exfiltrated data is a CSRF token, an API key or the cookie itself.
  • ☐ Note the reach of stored XSS: every viewer of the page, or only the author, and for how long the value persists.

Remediation

Structural fix

  • • Encode at the sink for its context, using the template engine auto-escaping or a context-aware encoder. Do not sanitise on input and assume every sink is then safe.
  • • Write text with textContent and remove innerHTML, eval and new Function on untrusted data. Where rich HTML is a product requirement, pass it through DOMPurify with an explicit allowlist.

Compensating control

  • • A CSP with no unsafe-inline, per-request nonces and no wildcard script hosts, so an injected tag fails even if a sink is missed.
  • • HttpOnly and SameSite on the session cookie, Trusted Types where the browser supports them, and a sanitising step on the stored-content write path for user HTML.

CWE References: CWE-79