XSS Remediation
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 Attack & Defense Flow
📑 Table of Contents
🛡️ Primary Defenses
🧪 Validation & Testing
⚠️ 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 (< → <) 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 executeCSP 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 charsEncoding 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.
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<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,postMessageand 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
textContentand removeinnerHTML,evalandnew Functionon 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. - •
HttpOnlyandSameSiteon 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