143 lines
6.0 KiB
PHP
143 lines
6.0 KiB
PHP
<?php
|
|
// ─── HTML sanitisation ───────────────────────────────────────────────────────
|
|
// Admin-authored rich text (Summernote) and the Google-Maps embed are rendered
|
|
// raw on the public site. To keep that from being a stored-XSS vector if an
|
|
// admin/staff account is ever misused, every such value is passed through this
|
|
// allowlist sanitiser at OUTPUT time (covers legacy rows too, not just new saves).
|
|
//
|
|
// Dependency-free: uses ext-dom (libxml). Falls back to a strip_tags allowlist
|
|
// if DOM is unavailable. Disallowed elements are dropped wholesale; on every
|
|
// surviving element only whitelisted attributes remain, and href/src schemes
|
|
// are validated (no javascript:/data:/vbscript:).
|
|
//
|
|
// clean_richtext($html) — modalidades.descricao, events.description, …
|
|
// clean_map_embed($html) — fitme3070_info.google_maps (single Google iframe)
|
|
|
|
// Tags allowed in general rich text (no attributes unless listed).
|
|
const HTML_RICHTEXT_TAGS = [
|
|
'p' => [], '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 <iframe> 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, '<p><br><strong><b><em><i><u><ul><ol><li><h2><h3><h4><blockquote><span><a>');
|
|
}
|
|
|
|
$doc = new DOMDocument();
|
|
$prev = libxml_use_internal_errors(true);
|
|
// The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep it a fragment
|
|
// (no <html>/<body>/doctype wrappers added).
|
|
$doc->loadHTML(
|
|
'<?xml encoding="utf-8"?><div id="__root__">' . $html . '</div>',
|
|
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);
|
|
}
|