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:
- Attacker crafts a malicious URL containing JavaScript
- Victim clicks the link (often from phishing email)
- Server includes the script in the response without sanitization
- 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:
- Attacker submits malicious script through forms
- Script is stored in database without sanitization
- Every user viewing the content executes the script
- 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:
- Strict: No inline scripts/styles, specific sources only
- Moderate: Nonces for inline content, trusted CDNs
- 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();
?>
Related Topics
- PHP Input Validation - Comprehensive input validation
- PHP SQL Injection Prevention - Database security
- PHP CSRF Protection - Cross-site request forgery prevention
- PHP Authentication Systems - User authentication security
- PHP Security - Overall security principles
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.