/** * Lightweight HTML sanitizer using DOMDocument + blacklist * For production use, consider installing HTMLPurifier via Composer. */ function sanitize_html($html) { if (empty($html)) return ''; // Step 1: Remove script, style, and dangerous tags via regex pre-clean $html = preg_replace('#<(script|style|iframe|object|embed|form|input|button)[^>]*>.*?#is', '', $html); $html = preg_replace('/<[^>]+\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove on* attributes $html = preg_replace('/<[^>]+\s+style\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove style attrs // Step 2: Use DOMDocument to re-serialize (normalizes malformed HTML) libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML('' . "\n" . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); // Step 3: Walk all elements and enforce whitelist $allowed_tags = [ 'h1','h2','h3','h4','h5','h6', 'p','br','strong','em','u','s','del','sup','sub', 'blockquote','pre','code', 'ul','ol','li', 'a','img', 'table','thead','tbody','tr','th','td', 'hr','span', 'div','section','article','header','footer', ]; $allowed_attrs = [ 'href', 'target', 'src', 'alt', 'width', 'height', 'class', 'id', ]; $xpath = new DOMXPath($dom); $all_elements = $xpath->query('//*'); $to_remove = []; foreach ($all_elements as $el) { $tag = strtolower($el->nodeName); // Remove disallowed tags entirely if (!in_array($tag, $allowed_tags)) { // Keep children, remove tag wrapper foreach ($el->childNodes as $child) { $el->parentNode->insertBefore($child->cloneNode(true), $el); } $to_remove[] = $el; continue; } // Remove disallowed attributes $attrs_to_remove = []; if ($el->attributes) { foreach ($el->attributes as $attr) { if (!in_array(strtolower($attr->nodeName), $allowed_attrs)) { $attrs_to_remove[] = $attr; } } foreach ($attrs_to_remove as $attr) { $el->removeAttributeNode($attr); } } // Sanitize href/src URLs (allow only http/https/data) if ($el->hasAttribute('href')) { $href = $el->getAttribute('href'); if (!preg_match('#^https?://|mailto:|tel:|data:#i', $href)) { $el->removeAttribute('href'); } } if ($el->hasAttribute('src')) { $src = $el->getAttribute('src'); if (!preg_match('#^https?://|data:#i', $src)) { $el->removeAttribute('src'); } } } foreach ($to_remove as $el) { $el->parentNode->removeChild($el); } $body = $dom->getElementsByTagName('body')->item(0); $output = ''; if ($body) { foreach ($body->childNodes as $child) { $output .= $dom->saveHTML($child); } } return trim($output); } /** * Auto-detect base URL from request */ function get_site_url() { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return $scheme . '://' . $host; } /** * Rate limiting for comment submissions */ function rate_limit($key, $max_requests = 5, $window_seconds = 60) { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $cache_file = '/tmp/rate_limit_' . md5($key . '_' . $ip) . '.json'; $now = time(); $data = []; if (file_exists($cache_file)) { $data = json_decode(file_get_contents($cache_file), true) ?: []; } // Remove expired entries $data = array_filter($data, function($ts) use ($now, $window_seconds) { return ($now - $ts) < $window_seconds; }); if (count($data) >= $max_requests) { return false; } $data[] = $now; file_put_contents($cache_file, json_encode($data)); return true; }