1. php
  2. /security
  3. /xss-protection

PHP XSS Protection

Introduction to XSS (Cross-Site Scripting)

Cross-Site Scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts into web applications. These scripts are then executed in other users' browsers, potentially stealing sensitive information, hijacking user sessions, or performing unauthorized actions.

XSS consistently ranks among the top web application security risks according to OWASP, making proper prevention techniques essential for secure PHP development.

Understanding the XSS Threat

XSS attacks exploit the trust users have in websites. When a site displays user-generated content without proper sanitization, attackers can inject JavaScript that:

Steals Sensitive Data: Access cookies, session tokens, and form data Hijacks User Sessions: Impersonate users by stealing authentication credentials
Defaces Websites: Modify page content to spread misinformation Performs Actions: Submit forms, make purchases, or change settings without user consent Spreads Malware: Redirect users to malicious sites or download malware

The danger of XSS is that it turns your trusted website into an attack vector against your own users.

Types of XSS Attacks

Understanding different XSS types helps implement appropriate defenses:

Reflected XSS

<?php
// VULNERABLE: Reflected XSS example
if (isset($_GET['search'])) {
    echo "<h2>Search results for: " . $_GET['search'] . "</h2>";
    // Malicious URL: index.php?search=<script>alert('XSS')</script>
}

// SECURE: Proper output encoding
if (isset($_GET['search'])) {
    echo "<h2>Search results for: " . htmlspecialchars($_GET['search'], ENT_QUOTES, 'UTF-8') . "</h2>";
}
?>

Reflected XSS Explained:

Attack Vector: Malicious scripts are embedded in URLs or form submissions and immediately reflected back to the user.

How It Works:

  1. Attacker crafts a malicious URL containing JavaScript
  2. Victim clicks the link (often from phishing email)
  3. Server includes the script in the response without sanitization
  4. Victim's browser executes the malicious script

Common Targets:

  • Search boxes
  • Error messages
  • Form validation messages
  • URL parameters displayed on page

Defense Strategy: Always encode output, never trust user input, validate on both client and server side.

Stored XSS

<?php
// VULNERABLE: Stored XSS in comments system
class VulnerableCommentSystem
{
    private $pdo;
    
    public function addComment($name, $comment) 
    {
        $stmt = $this->pdo->prepare("INSERT INTO comments (name, comment) VALUES (?, ?)");
        $stmt->execute([$name, $comment]); // Stores malicious script
    }
    
    public function displayComments() 
    {
        $stmt = $this->pdo->query("SELECT * FROM comments");
        while ($row = $stmt->fetch()) {
            echo "<div>";
            echo "<strong>" . $row['name'] . "</strong>: "; // XSS vulnerability
            echo $row['comment']; // XSS vulnerability
            echo "</div>";
        }
    }
}

// SECURE: Proper XSS protection
class SecureCommentSystem
{
    private $pdo;
    
    public function addComment($name, $comment) 
    {
        // Input validation and sanitization
        $name = $this->validateAndSanitize($name, 50);
        $comment = $this->validateAndSanitize($comment, 1000);
        
        $stmt = $this->pdo->prepare("INSERT INTO comments (name, comment) VALUES (?, ?)");
        $stmt->execute([$name, $comment]);
    }
    
    public function displayComments() 
    {
        $stmt = $this->pdo->query("SELECT * FROM comments");
        while ($row = $stmt->fetch()) {
            echo "<div>";
            echo "<strong>" . $this->escapeOutput($row['name']) . "</strong>: ";
            echo $this->escapeOutput($row['comment']);
            echo "</div>";
        }
    }
    
    private function validateAndSanitize($input, $maxLength) 
    {
        $input = trim($input);
        if (strlen($input) > $maxLength) {
            throw new InvalidArgumentException("Input too long");
        }
        return $input;
    }
    
    private function escapeOutput($text) 
    {
        return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
    }
}
?>

Stored XSS Deep Dive:

Persistence Threat: Unlike reflected XSS, stored XSS persists in your database, affecting all users who view the infected content.

Attack Lifecycle:

  1. Attacker submits malicious script through forms
  2. Script is stored in database without sanitization
  3. Every user viewing the content executes the script
  4. Damage multiplies with each page view

High-Risk Areas:

  • User profiles and bios
  • Comments and forums
  • Message boards
  • Review systems
  • Any user-generated content

Defense Layers:

  • Input Validation: Restrict what can be submitted
  • Storage: Store raw data, not pre-rendered HTML
  • Output Encoding: Escape when displaying, not when storing
  • Content Security Policy: Additional browser-level protection

Output Encoding and Escaping

HTML Context Escaping

<?php
class OutputEncoder 
{
    public static function html($text) 
    {
        return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
    
    public static function htmlAttribute($text) 
    {
        // For HTML attributes, encode more aggressively
        return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
    
    public static function javascript($text) 
    {
        // For JavaScript context, use JSON encoding
        return json_encode($text, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
    }
    
    public static function css($text) 
    {
        // For CSS context, escape special characters
        return addcslashes($text, "\x00..\x1f\x7f..\xff\\");
    }
    
    public static function url($text) 
    {
        // For URL context
        return rawurlencode($text);
    }
}

// Usage examples
$userInput = "<script>alert('XSS')</script>";
$userName = "O'Malley";
$searchQuery = "test & example";

// In HTML content
echo "<p>Welcome " . OutputEncoder::html($userName) . "</p>";

// In HTML attributes
echo "<input value='" . OutputEncoder::htmlAttribute($userInput) . "'>";

// In JavaScript
echo "<script>var userName = " . OutputEncoder::javascript($userName) . ";</script>";

// In URLs
echo "<a href='search.php?q=" . OutputEncoder::url($searchQuery) . "'>Search</a>";
?>

Context-Aware Encoding Explained:

Why Context Matters: Different contexts have different special characters and escape requirements. Using the wrong encoding can leave vulnerabilities or break functionality.

HTML Context:

  • Escapes: <, >, &, ", '
  • Prevents HTML tag injection
  • Safe for text content between tags
  • Use ENT_QUOTES to handle both single and double quotes

JavaScript Context:

  • JSON encoding handles all special characters
  • Prevents script injection in JavaScript strings
  • Hex encoding flags prevent breaking out of script tags
  • Always quote JavaScript strings

CSS Context:

  • Escapes control characters and backslashes
  • Prevents CSS expression injection
  • Important for dynamic styles
  • Consider avoiding dynamic CSS when possible

URL Context:

  • Encodes special URL characters
  • Prevents parameter pollution
  • Maintains URL structure
  • Use rawurlencode() for path components

Context-Aware Template System

<?php
class SecureTemplate 
{
    private $data = [];
    private $templatePath;
    
    public function __construct($templatePath) 
    {
        $this->templatePath = $templatePath;
    }
    
    public function assign($key, $value) 
    {
        $this->data[$key] = $value;
    }
    
    public function render() 
    {
        // Create escaped versions of all data
        $escaped = [];
        foreach ($this->data as $key => $value) {
            $escaped[$key] = $this->createEscapedData($value);
        }
        
        extract($escaped);
        include $this->templatePath;
    }
    
    private function createEscapedData($data) 
    {
        if (is_string($data)) {
            return [
                'raw' => $data,
                'html' => OutputEncoder::html($data),
                'attr' => OutputEncoder::htmlAttribute($data),
                'js' => OutputEncoder::javascript($data),
                'url' => OutputEncoder::url($data),
                'css' => OutputEncoder::css($data)
            ];
        }
        
        if (is_array($data)) {
            $result = [];
            foreach ($data as $key => $value) {
                $result[$key] = $this->createEscapedData($value);
            }
            return $result;
        }
        
        return $data;
    }
}

// Template usage (template.php)
?>
<!DOCTYPE html>
<html>
<head>
    <title><?= $title['html'] ?></title>
    <style>
        .highlight { color: <?= $color['css'] ?>; }
    </style>
</head>
<body>
    <h1><?= $title['html'] ?></h1>
    <p><?= $content['html'] ?></p>
    <a href="details.php?id=<?= $id['url'] ?>">View Details</a>
    
    <script>
        var config = {
            title: <?= $title['js'] ?>,
            content: <?= $content['js'] ?>
        };
    </script>
</body>
</html>

<?php
// Usage
$template = new SecureTemplate('template.php');
$template->assign('title', $_GET['title'] ?? 'Default Title');
$template->assign('content', $_POST['content'] ?? 'Default content');
$template->assign('id', $_GET['id'] ?? 1);
$template->assign('color', $_GET['color'] ?? '#000000');
$template->render();
?>

Secure Template System Benefits:

Automatic Escaping:

  • Developers can't forget to escape
  • Context-appropriate escaping always available
  • Raw data accessible when needed
  • Reduces security burden on developers

Multiple Contexts:

  • Same data escaped for different contexts
  • No need to remember which function to use
  • Consistent escaping throughout templates
  • Easy to audit and verify

Nested Data Support:

  • Arrays and objects handled recursively
  • Complex data structures supported
  • Maintains escaping at all levels
  • Flexible for real applications

Input Validation and Sanitization

Comprehensive Input Filter

<?php
class InputFilter 
{
    public static function sanitizeString($input, $maxLength = null) 
    {
        $input = trim($input);
        $input = stripslashes($input);
        
        if ($maxLength && strlen($input) > $maxLength) {
            throw new InvalidArgumentException("Input exceeds maximum length");
        }
        
        return $input;
    }
    
    public static function sanitizeHtml($input, $allowedTags = []) 
    {
        if (empty($allowedTags)) {
            return strip_tags($input);
        }
        
        $allowedTagsString = '<' . implode('><', $allowedTags) . '>';
        return strip_tags($input, $allowedTagsString);
    }
    
    public static function sanitizeEmail($input) 
    {
        $email = filter_var($input, FILTER_SANITIZE_EMAIL);
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email format");
        }
        return $email;
    }
    
    public static function sanitizeUrl($input) 
    {
        $url = filter_var($input, FILTER_SANITIZE_URL);
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
            throw new InvalidArgumentException("Invalid URL format");
        }
        return $url;
    }
    
    public static function sanitizeInt($input, $min = null, $max = null) 
    {
        $int = filter_var($input, FILTER_VALIDATE_INT);
        if ($int === false) {
            throw new InvalidArgumentException("Invalid integer");
        }
        
        if ($min !== null && $int < $min) {
            throw new InvalidArgumentException("Value below minimum");
        }
        
        if ($max !== null && $int > $max) {
            throw new InvalidArgumentException("Value above maximum");
        }
        
        return $int;
    }
    
    public static function removeXssPatterns($input) 
    {
        $xssPatterns = [
            '/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi',
            '/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi',
            '/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi',
            '/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi',
            '/javascript:/i',
            '/vbscript:/i',
            '/onload\s*=/i',
            '/onerror\s*=/i',
            '/onclick\s*=/i',
            '/onmouseover\s*=/i'
        ];
        
        foreach ($xssPatterns as $pattern) {
            $input = preg_replace($pattern, '', $input);
        }
        
        return $input;
    }
}

// Usage example
try {
    $name = InputFilter::sanitizeString($_POST['name'], 100);
    $email = InputFilter::sanitizeEmail($_POST['email']);
    $age = InputFilter::sanitizeInt($_POST['age'], 1, 120);
    $bio = InputFilter::sanitizeHtml($_POST['bio'], ['p', 'br', 'strong', 'em']);
    $website = InputFilter::sanitizeUrl($_POST['website']);
    
    // Process sanitized data...
    
} catch (InvalidArgumentException $e) {
    echo "Invalid input: " . htmlspecialchars($e->getMessage());
}
?>

Input Filtering Strategy:

Layered Defense:

  • Sanitization: Remove or modify dangerous characters
  • Validation: Ensure data meets expected format
  • Length Limits: Prevent buffer overflow attacks
  • Type Checking: Ensure correct data types

Filter Methods Explained:

sanitizeString():

  • Removes leading/trailing whitespace
  • Strips backslashes (magic quotes legacy)
  • Enforces length limits
  • Basic cleanup for text inputs

sanitizeHtml():

  • Allows safe HTML subset
  • Removes all non-whitelisted tags
  • Prevents script injection
  • Useful for rich text editors

sanitizeEmail() & sanitizeUrl():

  • Uses PHP's built-in filters
  • Removes invalid characters
  • Validates format after sanitization
  • Throws exceptions for invalid data

removeXssPatterns():

  • Pattern-based XSS detection
  • Removes common attack vectors
  • Not foolproof - use with output encoding
  • Last line of defense

Content Security Policy (CSP)

CSP Implementation

<?php
class ContentSecurityPolicy 
{
    private $directives = [];
    
    public function __construct() 
    {
        $this->setDefaults();
    }
    
    private function setDefaults() 
    {
        $this->directives = [
            'default-src' => ["'self'"],
            'script-src' => ["'self'"],
            'style-src' => ["'self'", "'unsafe-inline'"],
            'img-src' => ["'self'", 'data:', 'https:'],
            'font-src' => ["'self'"],
            'connect-src' => ["'self'"],
            'media-src' => ["'self'"],
            'object-src' => ["'none'"],
            'frame-src' => ["'none'"],
            'base-uri' => ["'self'"],
            'form-action' => ["'self'"],
            'frame-ancestors' => ["'none'"],
            'upgrade-insecure-requests' => []
        ];
    }
    
    public function addSource($directive, $source) 
    {
        if (!isset($this->directives[$directive])) {
            $this->directives[$directive] = [];
        }
        
        if (!in_array($source, $this->directives[$directive])) {
            $this->directives[$directive][] = $source;
        }
        
        return $this;
    }
    
    public function allowInlineScripts($nonce = null) 
    {
        if ($nonce) {
            $this->addSource('script-src', "'nonce-{$nonce}'");
        } else {
            $this->addSource('script-src', "'unsafe-inline'");
        }
        return $this;
    }
    
    public function allowInlineStyles() 
    {
        $this->addSource('style-src', "'unsafe-inline'");
        return $this;
    }
    
    public function allowGoogleFonts() 
    {
        $this->addSource('font-src', 'fonts.gstatic.com');
        $this->addSource('style-src', 'fonts.googleapis.com');
        return $this;
    }
    
    public function allowCdn($domain) 
    {
        $this->addSource('script-src', $domain);
        $this->addSource('style-src', $domain);
        return $this;
    }
    
    public function generateHeader() 
    {
        $policy = [];
        
        foreach ($this->directives as $directive => $sources) {
            if (empty($sources)) {
                $policy[] = $directive;
            } else {
                $policy[] = $directive . ' ' . implode(' ', $sources);
            }
        }
        
        return implode('; ', $policy);
    }
    
    public function sendHeader() 
    {
        header('Content-Security-Policy: ' . $this->generateHeader());
    }
    
    public function generateNonce() 
    {
        return base64_encode(random_bytes(16));
    }
}

// Usage
$csp = new ContentSecurityPolicy();
$csp->allowGoogleFonts()
    ->allowCdn('https://cdnjs.cloudflare.com')
    ->addSource('connect-src', 'https://api.example.com');

$nonce = $csp->generateNonce();
$csp->allowInlineScripts($nonce);

$csp->sendHeader();
?>

<!DOCTYPE html>
<html>
<head>
    <title>Secure Page</title>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
    <h1>XSS Protected Page</h1>
    
    <!-- Inline script with nonce -->
    <script nonce="<?= htmlspecialchars($nonce) ?>">
        console.log('This script is allowed by CSP');
    </script>
    
    <!-- External script from allowed CDN -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
</body>
</html>

CSP Defense in Depth:

How CSP Works:

  • Browser-enforced security policy
  • Restricts resource loading sources
  • Blocks inline scripts by default
  • Reports policy violations

Directive Breakdown:

default-src: Fallback for other directives script-src: Controls JavaScript sources style-src: Controls CSS sources img-src: Controls image sources connect-src: Controls AJAX, WebSocket, EventSource

Security Levels:

  1. Strict: No inline scripts/styles, specific sources only
  2. Moderate: Nonces for inline content, trusted CDNs
  3. Relaxed: Some unsafe-inline allowed (less secure)

Nonce Strategy:

  • Unique nonce per request
  • Allows specific inline scripts
  • Prevents injected scripts
  • Balance between security and functionality

Advanced XSS Protection

HTML Purifier Integration

<?php
// Using HTMLPurifier for complex HTML sanitization
// composer require ezyang/htmlpurifier

use HTMLPurifier;
use HTMLPurifier_Config;

class AdvancedHtmlSanitizer 
{
    private $purifier;
    
    public function __construct() 
    {
        $config = HTMLPurifier_Config::createDefault();
        
        // Configure allowed elements and attributes
        $config->set('HTML.Allowed', 'p,br,strong,em,u,ol,ul,li,a[href],img[src|alt|width|height]');
        
        // Set caching directory
        $config->set('Cache.SerializerPath', '/tmp');
        
        // Additional security settings
        $config->set('HTML.Nofollow', true);
        $config->set('HTML.TargetBlank', true);
        $config->set('URI.DisableExternalResources', true);
        
        $this->purifier = new HTMLPurifier($config);
    }
    
    public function purify($html) 
    {
        return $this->purifier->purify($html);
    }
    
    public function purifyWithProfile($html, $profile) 
    {
        $config = HTMLPurifier_Config::createDefault();
        
        switch ($profile) {
            case 'basic':
                $config->set('HTML.Allowed', 'p,br,strong,em');
                break;
                
            case 'comment':
                $config->set('HTML.Allowed', 'p,br,strong,em,a[href],blockquote');
                break;
                
            case 'article':
                $config->set('HTML.Allowed', 'p,br,strong,em,u,ol,ul,li,a[href],img[src|alt],h3,h4,blockquote');
                break;
        }
        
        $purifier = new HTMLPurifier($config);
        return $purifier->purify($html);
    }
}

// Usage
$sanitizer = new AdvancedHtmlSanitizer();

$userInput = '<p>Valid content</p><script>alert("XSS")</script><img src="image.jpg" alt="Valid">';
$cleanHtml = $sanitizer->purify($userInput);
// Output: <p>Valid content</p><img src="image.jpg" alt="Valid" />

$commentHtml = '<p>Great article!</p><a href="http://example.com">Check this</a><script>steal()</script>';
$cleanComment = $sanitizer->purifyWithProfile($commentHtml, 'comment');
// Output: <p>Great article!</p><a href="http://example.com" target="_blank" rel="nofollow">Check this</a>
?>

When to Use HTML Purifier:

HTML Purifier provides industrial-strength HTML sanitization for cases where you must accept rich HTML content:

Use Cases:

  • WYSIWYG editors
  • User-generated articles
  • Email content display
  • Legacy content migration

Benefits:

  • Standards-compliant HTML output
  • Extensive configuration options
  • Removes malicious code while preserving formatting
  • Handles complex nested structures

Configuration Example:

$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,em,a[href],img[src|alt]');
$config->set('URI.DisableExternalResources', true);
$purifier = new HTMLPurifier($config);
$clean_html = $purifier->purify($dirty_html);

Remember: XSS protection requires constant vigilance. Always encode output, validate input, implement CSP, and stay updated on new attack vectors. Security is not a feature - it's a continuous process.

Security Headers

Comprehensive Security Headers

<?php
class SecurityHeaders 
{
    public static function setAll() 
    {
        // Prevent XSS attacks
        header('X-XSS-Protection: 1; mode=block');
        
        // Prevent content type sniffing
        header('X-Content-Type-Options: nosniff');
        
        // Prevent clickjacking
        header('X-Frame-Options: DENY');
        
        // Referrer policy
        header('Referrer-Policy: strict-origin-when-cross-origin');
        
        // Feature policy
        header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
        
        // HSTS (if using HTTPS)
        if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
            header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
        }
    }
    
    public static function setCsp($policy) 
    {
        header('Content-Security-Policy: ' . $policy);
    }
    
    public static function setNoCacheHeaders() 
    {
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        header('Expires: 0');
    }
}

// Apply security headers to all pages
SecurityHeaders::setAll();
?>

Summary

XSS protection in PHP requires a multi-layered approach:

  • Output Encoding: Always escape output based on context (HTML, JavaScript, CSS, URL)
  • Input Validation: Validate and sanitize all user input at entry points
  • Content Security Policy: Implement CSP headers to prevent script injection
  • Security Headers: Use browser security features through proper headers
  • HTML Sanitization: Use libraries like HTMLPurifier for complex HTML content

Key principles:

  • Never trust user input
  • Always escape output for the correct context
  • Use whitelist approaches for allowed content
  • Implement defense in depth with multiple protection layers
  • Regularly audit and test for XSS vulnerabilities

Proper XSS protection is essential for maintaining user trust and preventing data breaches in PHP applications.