// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\blog\library; /** * 文章正文 HTML 白名单净化器 * * 用途:文章正文在前台是 {$article.content|raw} 直出(富文本必须原样输出), * 因此必须在**入库前**做白名单净化,否则一旦内容来自不可信来源 * (前台投稿、Word/网页导入、编辑器被绕过)就是存储型 XSS。 * * 实现说明:基于 PHP DOM 扩展做「标签白名单 + 属性白名单 + URL 协议白名单」, * 不依赖外部组件(HTMLPurifier)。libxml 不可用时降级为 strip_tags 粗过滤。 */ class HtmlSanitizer { /** 允许的标签(小写) */ private const ALLOW_TAGS = [ 'p', 'br', 'div', 'span', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small', 'a', 'img', 'figure', 'figcaption', 'hr', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', ]; /** 整块丢弃的标签(连同内部内容一起删除) */ private const DROP_TAGS = [ 'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'input', 'button', 'select', 'option', 'textarea', 'label', 'link', 'meta', 'base', 'noscript', 'svg', 'math', 'audio', 'video', 'source', 'track', ]; /** 允许保留的属性(小写) */ private const ALLOW_ATTR = [ 'class', 'id', 'style', 'title', 'alt', 'src', 'href', 'target', 'rel', 'width', 'height', 'align', 'colspan', 'rowspan', ]; /** * 净化 HTML * * @param string $html 原始富文本 * @return string 净化后的 HTML(可直接入库与直出) */ public static function clean(string $html): string { $html = trim($html); if ($html === '') { return ''; } if (!class_exists('DOMDocument')) { return self::fallback($html); } $prev = libxml_use_internal_errors(true); $doc = new \DOMDocument('1.0', 'UTF-8'); // 加 XML 编码声明,避免中文被按 ISO-8859-1 解析 $loaded = $doc->loadHTML('' . $html); libxml_clear_errors(); libxml_use_internal_errors($prev); if (!$loaded) { return self::fallback($html); } $body = $doc->getElementsByTagName('body')->item(0); if (!$body) { return self::fallback($html); } self::walk($body); $out = ''; foreach ($body->childNodes as $child) { $out .= $doc->saveHTML($child); } return trim($out); } /** * 递归净化节点 */ private static function walk(\DOMNode $node): void { $children = []; foreach ($node->childNodes as $child) { $children[] = $child; } foreach ($children as $child) { // 注释节点一律删除(IE 条件注释可携带脚本) if ($child->nodeType === XML_COMMENT_NODE) { $node->removeChild($child); continue; } if ($child->nodeType !== XML_ELEMENT_NODE) { continue; } /** @var \DOMElement $child */ $tag = strtolower($child->nodeName); // 危险标签:连同内容一起丢掉 if (in_array($tag, self::DROP_TAGS, true)) { $node->removeChild($child); continue; } // 非白名单标签:只拆掉外壳,保留内容(如 font/center 等富文本残留) if (!in_array($tag, self::ALLOW_TAGS, true)) { self::walk($child); self::unwrap($child); continue; } self::cleanAttrs($child); self::walk($child); } } /** * 拆掉元素外壳,把子节点提到父级 */ private static function unwrap(\DOMElement $el): void { $parent = $el->parentNode; if (!$parent) { return; } $children = []; foreach ($el->childNodes as $c) { $children[] = $c; } foreach ($children as $c) { $parent->insertBefore($c, $el); } $parent->removeChild($el); } /** * 属性白名单 + URL 协议校验 */ private static function cleanAttrs(\DOMElement $el): void { $names = []; foreach ($el->attributes as $attr) { $names[] = $attr->nodeName; } foreach ($names as $name) { $lname = strtolower($name); $value = (string) $el->getAttribute($name); // on* 事件属性一律删除(白名单里本来也没有,这里是双保险) if (strpos($lname, 'on') === 0) { $el->removeAttribute($name); continue; } if (!in_array($lname, self::ALLOW_ATTR, true)) { $el->removeAttribute($name); continue; } // src / href 必须走安全协议 if (($lname === 'src' || $lname === 'href') && self::isUnsafeUrl($value)) { $el->removeAttribute($name); continue; } // style 里禁止 expression / javascript / url() 等可执行写法 if ($lname === 'style' && preg_match('/(expression|javascript|vbscript|url\s*\()/i', $value)) { $el->removeAttribute($name); continue; } } // 链接统一加 rel:防 window.opener 劫持 + 不向站外传递权重 if (strtolower($el->nodeName) === 'a' && $el->getAttribute('href') !== '') { $el->setAttribute('rel', 'nofollow noopener noreferrer'); } } /** * URL 是否不安全 */ private static function isUnsafeUrl(string $value): bool { $v = strtolower((string) preg_replace('/[\s\x00-\x1f]/', '', $value)); if ($v === '') { return true; } // 可执行协议 if (preg_match('/^(javascript|vbscript|file|about|blob):/', $v)) { return true; } // data: 仅允许图片 if (preg_match('/^data:/', $v) && !preg_match('/^data:image\//', $v)) { return true; } return false; } /** * 降级方案(DOM 不可用时):粗过滤危险标签 */ private static function fallback(string $html): string { $html = (string) preg_replace('#<\s*(script|style|iframe|object|embed|form)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $html); $html = (string) preg_replace('/\son[a-z]+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/is', '', $html); return strip_tags($html, '