| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Content\Fields; defined('BASEPATH') || die('Direct access to this location is not allowed.'); /** Conservative allow-list sanitizer for administrator-authored rich text. */ class HtmlSanitizer { protected static $tags = array( 'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'blockquote', 'ul', 'ol', 'li', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'pre', 'code', 'hr', 'sup', 'sub' ); protected static $dropWithContent = array('script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'option', 'svg', 'math', 'template'); protected static $attributes = array( '*' => array('class', 'title', 'style'), 'a' => array('href', 'target', 'rel'), 'img' => array('src', 'alt', 'width', 'height', 'loading'), 'ol' => array('start', 'type'), 'li' => array('value'), 'table' => array('width'), 'th' => array('colspan', 'rowspan', 'scope'), 'td' => array('colspan', 'rowspan'), 'col' => array('span', 'width'), ); protected static $styles = array( 'text-align', 'color', 'background-color', 'font-size', 'font-family', 'font-weight', 'font-style', 'text-decoration', 'line-height', 'margin-left', 'margin-right', 'padding-left', 'padding-right', 'width', 'height', 'max-width', 'vertical-align', 'white-space' ); public static function clean($html) { $html = (string) $html; if ($html === '' || trim($html) === '') { return $html; } if (!class_exists('DOMDocument')) { return self::fallback($html); } $previous = libxml_use_internal_errors(true); $document = new \DOMDocument('1.0', 'UTF-8'); $wrapped = '
' . $html . '
'; $loaded = $document->loadHTML('' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); if (!$loaded) { libxml_clear_errors(); libxml_use_internal_errors($previous); return self::fallback($html); } $root = null; foreach ($document->getElementsByTagName('div') as $node) { if ($node->getAttribute('data-ave-root') === '1') { $root = $node; break; } } if (!$root) { libxml_clear_errors(); libxml_use_internal_errors($previous); return self::fallback($html); } self::sanitizeChildren($root); $result = ''; foreach ($root->childNodes as $child) { $result .= $document->saveHTML($child); } libxml_clear_errors(); libxml_use_internal_errors($previous); return $result; } protected static function sanitizeChildren(\DOMNode $parent) { $children = array(); foreach ($parent->childNodes as $child) { $children[] = $child; } foreach ($children as $child) { if ($child->nodeType !== XML_ELEMENT_NODE) { continue; } $tag = strtolower($child->nodeName); if (in_array($tag, self::$dropWithContent, true)) { $parent->removeChild($child); continue; } if (!in_array($tag, self::$tags, true)) { self::sanitizeChildren($child); while ($child->firstChild) { $parent->insertBefore($child->firstChild, $child); } $parent->removeChild($child); continue; } self::sanitizeAttributes($child, $tag); self::sanitizeChildren($child); } } protected static function sanitizeAttributes(\DOMElement $node, $tag) { $allowed = self::$attributes['*']; if (isset(self::$attributes[$tag])) { $allowed = array_merge($allowed, self::$attributes[$tag]); } $remove = array(); foreach ($node->attributes as $attribute) { $name = strtolower($attribute->name); if (!in_array($name, $allowed, true) && strpos($name, 'data-') !== 0 && strpos($name, 'aria-') !== 0) { $remove[] = $attribute->name; continue; } $value = trim((string) $attribute->value); if (($name === 'href' || $name === 'src') && !self::safeUrl($value)) { $remove[] = $attribute->name; } elseif ($name === 'style') { $style = self::safeStyle($value); if ($style === '') { $remove[] = $attribute->name; } else { $node->setAttribute('style', $style); } } } foreach ($remove as $name) { $node->removeAttribute($name); } if ($tag === 'a' && strtolower($node->getAttribute('target')) === '_blank') { $rel = preg_split('/\s+/', strtolower(trim($node->getAttribute('rel')))) ?: array(); $rel = array_values(array_unique(array_filter(array_merge($rel, array('noopener', 'noreferrer'))))); $node->setAttribute('rel', implode(' ', $rel)); } if ($tag === 'img' && !$node->hasAttribute('loading')) { $node->setAttribute('loading', 'lazy'); } } protected static function safeUrl($url) { $url = html_entity_decode(trim((string) $url), ENT_QUOTES, 'UTF-8'); if ($url === '' || $url[0] === '/' || $url[0] === '#' || strpos($url, './') === 0 || strpos($url, '../') === 0) { return true; } $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); return in_array($scheme, array('http', 'https', 'mailto', 'tel'), true); } protected static function safeStyle($style) { if (preg_match('/(?:expression|url\s*\(|javascript|vbscript|behavior\s*:|@import|-moz-binding)/i', (string) $style)) { return ''; } $out = array(); foreach (explode(';', (string) $style) as $rule) { $parts = explode(':', $rule, 2); if (count($parts) !== 2) { continue; } $property = strtolower(trim($parts[0])); $value = trim($parts[1]); if (in_array($property, self::$styles, true) && $value !== '' && !preg_match('/[<>]/', $value)) { $out[] = $property . ': ' . $value; } } return implode('; ', $out); } protected static function fallback($html) { $html = preg_replace('#<(script|style|iframe|object|embed|form|svg|math|template)\b[^>]*>.*?#is', '', (string) $html); $html = strip_tags($html, '