[], 'br' => [], 'hr' => [], 'div' => [], 'span' => [], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'ul' => [], 'ol' => [], 'li' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'blockquote' => [], 'a' => ['href', 'target', 'rel'], ]; // Elements removed entirely (content and all) when they appear anywhere. const HTML_DANGEROUS_TAGS = [ 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'option', 'link', 'meta', 'base', 'svg', 'math', 'audio', 'video', 'source', 'frame', 'frameset', 'applet', ]; function clean_richtext(?string $html): string { return html_sanitize((string) $html, HTML_RICHTEXT_TAGS); } // The maps field holds a single Google Maps embed. Allow exactly that. function clean_map_embed(?string $html): string { $allowed = [ 'iframe' => ['src', 'width', 'height', 'style', 'loading', 'allowfullscreen', 'referrerpolicy', 'title'], ]; return html_sanitize((string) $html, $allowed, true); } // Core sanitiser. $allowed maps tag => list of permitted attributes. // $allowMapsIframe gates iframe src to Google-Maps embed URLs only. function html_sanitize(string $html, array $allowed, bool $allowMapsIframe = false): string { $html = trim($html); if ($html === '') return ''; if (!class_exists('DOMDocument')) { // Conservative fallback: keep a safe inline tag set, drop ALL attributes // (strip_tags removes attributes with the tags it keeps's contents intact). return strip_tags($html, ''); } $doc = new DOMDocument(); $prev = libxml_use_internal_errors(true); // The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep it a fragment // (no //doctype wrappers added). $doc->loadHTML( '' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors($prev); $root = $doc->getElementById('__root__'); if (!$root) { // getElementById can miss without a DTD; fall back to first div. $root = $doc->getElementsByTagName('div')->item(0); } if (!$root) return ''; _html_sanitize_children($root, $allowed, $allowMapsIframe); $out = ''; foreach (iterator_to_array($root->childNodes) as $child) { $out .= $doc->saveHTML($child); } return trim($out); } // Recursively scrub a node's children in place. function _html_sanitize_children(DOMNode $node, array $allowed, bool $allowMapsIframe): void { foreach (iterator_to_array($node->childNodes) as $child) { if ($child->nodeType === XML_COMMENT_NODE) { $node->removeChild($child); continue; } if ($child->nodeType !== XML_ELEMENT_NODE) { continue; // plain text — safe (serialised escaped) } $tag = strtolower($child->nodeName); // Disallowed → remove the element and everything inside it. if (!isset($allowed[$tag])) { $node->removeChild($child); continue; } // Strip every attribute that isn't explicitly permitted for this tag, // and validate URL-bearing ones. if ($child->hasAttributes()) { foreach (iterator_to_array($child->attributes) as $attr) { $name = strtolower($attr->name); if (!in_array($name, $allowed[$tag], true)) { $child->removeAttribute($attr->name); // drops on*, style (rich text), etc. continue; } if (($name === 'href' || $name === 'src') && !_html_url_is_safe($attr->value, $tag === 'iframe' && $allowMapsIframe)) { $child->removeAttribute($attr->name); } } } // Harden links that open in a new tab. if ($tag === 'a' && strtolower($child->getAttribute('target')) === '_blank') { $child->setAttribute('rel', 'noopener noreferrer'); } _html_sanitize_children($child, $allowed, $allowMapsIframe); } } // Allow http(s)/mailto/tel/anchors/relative; for iframes, only Google-Maps // embeds. Always reject javascript:/data:/vbscript: and other schemes. function _html_url_is_safe(string $url, bool $isMapsIframe): bool { $url = trim($url); if ($url === '') return false; if (preg_match('/^\s*(javascript|data|vbscript|file):/i', $url)) return false; if ($isMapsIframe) { // Any Google-owned Maps URL (www.google.com/maps/embed, maps.google.pt/…). return (bool) preg_match('#^https://([a-z0-9-]+\.)*google\.[a-z.]+/maps#i', $url); } if (preg_match('#^(https?:|mailto:|tel:|/|\#|\?|\.)#i', $url)) return true; // No scheme at all → relative path (e.g. "home/contactos") → safe. return !preg_match('#^[a-z][a-z0-9+.\-]*:#i', $url); }
'); } $doc = new DOMDocument(); $prev = libxml_use_internal_errors(true); // The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep it a fragment // (no //doctype wrappers added). $doc->loadHTML( '' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors($prev); $root = $doc->getElementById('__root__'); if (!$root) { // getElementById can miss without a DTD; fall back to first div. $root = $doc->getElementsByTagName('div')->item(0); } if (!$root) return ''; _html_sanitize_children($root, $allowed, $allowMapsIframe); $out = ''; foreach (iterator_to_array($root->childNodes) as $child) { $out .= $doc->saveHTML($child); } return trim($out); } // Recursively scrub a node's children in place. function _html_sanitize_children(DOMNode $node, array $allowed, bool $allowMapsIframe): void { foreach (iterator_to_array($node->childNodes) as $child) { if ($child->nodeType === XML_COMMENT_NODE) { $node->removeChild($child); continue; } if ($child->nodeType !== XML_ELEMENT_NODE) { continue; // plain text — safe (serialised escaped) } $tag = strtolower($child->nodeName); // Disallowed → remove the element and everything inside it. if (!isset($allowed[$tag])) { $node->removeChild($child); continue; } // Strip every attribute that isn't explicitly permitted for this tag, // and validate URL-bearing ones. if ($child->hasAttributes()) { foreach (iterator_to_array($child->attributes) as $attr) { $name = strtolower($attr->name); if (!in_array($name, $allowed[$tag], true)) { $child->removeAttribute($attr->name); // drops on*, style (rich text), etc. continue; } if (($name === 'href' || $name === 'src') && !_html_url_is_safe($attr->value, $tag === 'iframe' && $allowMapsIframe)) { $child->removeAttribute($attr->name); } } } // Harden links that open in a new tab. if ($tag === 'a' && strtolower($child->getAttribute('target')) === '_blank') { $child->setAttribute('rel', 'noopener noreferrer'); } _html_sanitize_children($child, $allowed, $allowMapsIframe); } } // Allow http(s)/mailto/tel/anchors/relative; for iframes, only Google-Maps // embeds. Always reject javascript:/data:/vbscript: and other schemes. function _html_url_is_safe(string $url, bool $isMapsIframe): bool { $url = trim($url); if ($url === '') return false; if (preg_match('/^\s*(javascript|data|vbscript|file):/i', $url)) return false; if ($isMapsIframe) { // Any Google-owned Maps URL (www.google.com/maps/embed, maps.google.pt/…). return (bool) preg_match('#^https://([a-z0-9-]+\.)*google\.[a-z.]+/maps#i', $url); } if (preg_match('#^(https?:|mailto:|tel:|/|\#|\?|\.)#i', $url)) return true; // No scheme at all → relative path (e.g. "home/contactos") → safe. return !preg_match('#^[a-z][a-z0-9+.\-]*:#i', $url); }