<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
/**
* Sanitizes untrusted HTML against a permissive-but-safe allowlist.
* Built for rendering text/html email bodies inline without giving
* the message author the ability to run script, beacon to a tracker
* pixel via a stylesheet, or otherwise hijack the page.
*
* Approach: parse the input with DOMDocument, walk the tree
* post-order, drop elements whose tag is not on the allowlist,
* strip attributes that are not on the per-tag allowlist, and
* restrict href / src URLs to http, https, and mailto. The result
* is serialised back to HTML.
*
* What survives sanitisation:
* - structural and semantic tags (p, div, span, section, article,
* header, footer, aside, blockquote, etc.);
* - inline formatting (strong, b, em, i, u, s, small, mark, sub,
* sup, code, kbd, samp, var);
* - headings (h1 through h6) and breaks (br, hr);
* - lists (ul, ol, li, dl, dt, dd);
* - tables (table, thead, tbody, tfoot, tr, td, th, caption,
* colgroup, col) with colspan / rowspan / scope preserved;
* - hyperlinks (a) with href restricted to http(s) / mailto;
* - images (img) with src restricted to http(s); alt, width,
* height preserved;
* - a safe subset of inline style: colour, typography, spacing,
* border, list, display, and visibility declarations (so a
* message can, for instance, hide its own preheader).
*
* What does not survive:
* - script, style, link, meta, base, iframe, object, embed,
* form, input, button, select, textarea, applet, frame,
* frameset (any tag that can fetch, run, or load active
* content);
* - every on* event handler attribute (onclick, onload,
* onmouseover, onerror, etc.);
* - the unsafe part of a style attribute: any declaration whose
* property is not on the safe list (position, z-index, float,
* and friends, which can lift content over the page), whose
* value carries a hazard such as url() or expression() (CSS
* that beacons or, on legacy IE, runs script), or that sets a
* fixed-pixel width that would overflow a narrow pane;
* - href and src values with schemes other than http, https,
* or mailto -- in particular javascript: and data: schemes
* are dropped.
*
* Image src is allowed for usability with formatted mail, but a
* caller that wants to block remote images for privacy reasons
* (tracker pixels) can pre-process the output to remove image src
* attributes. A "block remote images" toggle in the mail UI is a
* sensible future extension; this class deliberately stops at
* the security boundary.
*
* @author Chris Pollett
*/
class HtmlSanitizer
{
/**
* Tags whose content is rendered as-is after their attributes
* are filtered. Anything not in this set is removed entirely
* along with its descendants.
*/
private const ALLOWED_TAGS = ['a', 'p', 'br', 'hr', 'span',
'div', 'section', 'article', 'header', 'footer', 'aside',
'main', 'nav', 'strong', 'b', 'em', 'i', 'u', 's',
'small', 'mark', 'sub', 'sup', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'blockquote', 'q', 'cite', 'code', 'pre', 'kbd', 'samp',
'var', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td',
'th', 'caption', 'colgroup', 'col', 'img', 'figure',
'figcaption', 'address', 'time'];
/**
* Attributes allowed on every allowed tag (the global set,
* keyed by '*') and tag-specific allowed attributes. An
* attribute on an allowed tag that appears in neither map is
* stripped.
*/
private const ALLOWED_ATTRS = [
'*' => ['title', 'lang', 'dir', 'style'],
'a' => ['href'],
'img' => ['src', 'alt', 'width', 'height'],
'td' => ['rowspan', 'colspan', 'headers'],
'th' => ['rowspan', 'colspan', 'scope', 'abbr'],
'col' => ['span'],
'colgroup' => ['span'],
'time' => ['datetime'],
];
/**
* Tags that are dropped without dropping their children.
* When the input is a full HTML document, the wrapping html,
* head, and body tags arrive at the sanitiser as elements
* that are not on the allowed list -- treating them like any
* other disallowed tag would discard the entire body content
* along with them. Instead we splice their children up into
* the parent and remove the wrapper. The children are then
* walked normally, so style/meta/link inside <head> are still
* dropped and the body content emerges at the top level of
* the cleaned output.
*
* center and font are obsolete presentational wrappers that
* HTML mail still uses heavily for layout (a newsletter often
* wraps its entire body in a single <center>). They carry no
* content of their own, so they are unwrapped rather than
* removed; dropping them with their subtree would blank out
* the whole message.
*/
private const UNWRAP_TAGS = ['html', 'body', 'head', 'center',
'font'];
/**
* URL schemes allowed in href and src attribute values. A
* relative URL (no scheme) is treated as safe and preserved
* unchanged so anchors to fragments within the same document
* continue to work; everything with a scheme that is not on
* this list is dropped.
*/
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];
/**
* Inline CSS properties kept when an element carries a style
* attribute. The set covers the colour, typography, spacing,
* border, and list formatting that formatted mail relies on,
* plus display and visibility so a message can hide its own
* preheader. It deliberately omits anything that can lift an
* element out of flow or over the surrounding page -- there
* is no position, top/right/bottom/left, z-index, float, or
* clear -- and omits min-width, which bulk mail commonly sets
* to a desktop pixel figure that then overflows a phone.
*/
private const SAFE_STYLE_PROPERTIES = ['color',
'background-color', 'background', 'font', 'font-family',
'font-size', 'font-style', 'font-weight', 'font-variant',
'line-height', 'letter-spacing', 'word-spacing',
'text-align', 'text-decoration', 'text-transform',
'text-indent', 'vertical-align', 'white-space',
'word-break', 'word-wrap', 'overflow-wrap', 'padding',
'padding-top', 'padding-right', 'padding-bottom',
'padding-left', 'margin', 'margin-top', 'margin-right',
'margin-bottom', 'margin-left', 'border', 'border-top',
'border-right', 'border-bottom', 'border-left',
'border-width', 'border-style', 'border-color',
'border-radius', 'border-collapse', 'border-spacing',
'list-style', 'list-style-type', 'list-style-position',
'caption-side', 'display', 'visibility', 'width',
'max-width', 'height', 'max-height'];
/**
* Substrings that, if present in a style value, cause the
* whole declaration to be dropped. url( would let a stylesheet
* fetch a remote resource (a tracker beacon, and a way around
* the remote-image block); expression( and behavior are legacy
* script-in-CSS; @import pulls a remote sheet; the comment and
* angle-bracket markers and the backslash defeat escape-based
* attempts to smuggle one of the above past this check.
*/
private const STYLE_VALUE_HAZARDS = ['url(', 'expression(',
'javascript:', '@import', '/*', '*/', '<', '>', '\\',
'behavior'];
/**
* Sanitises an HTML fragment, returning a string of HTML
* containing only allowlisted tags and attributes with safe
* URL schemes. The result is suitable for inline rendering in
* a content pane. An empty input returns the empty string.
*
* @param string $html the untrusted HTML fragment
* @param bool $block_remote_images when true, each remote
* image is marked so the browser does not fetch it on
* render: the src is left untouched, but loading="lazy"
* and a mail-blocked-image class are added. The class
* hides the image (display:none), so a lazy image has
* no layout box and is never near the viewport, and the
* browser does not fetch it. The caller offers a "load
* remote images" control that removes the class; the
* revealed image then loads natively through the normal
* image path. This defends against tracker-pixel beacons
* in mail without ever rewriting src.
* @return string sanitised HTML
*/
public static function sanitize($html, $block_remote_images = false)
{
if ($html === null || $html === '') {
return '';
}
$html = self::preNormalize($html);
$dom = new \DOMDocument('1.0', 'UTF-8');
/* libxml emits warnings for malformed HTML, which all
real-world mail is; capture and discard them so they do
not reach Yioop's global handler. */
$previous_state = libxml_use_internal_errors(true);
/* the XML processing instruction at the top is the
well-documented trick for forcing libxml to interpret
the bytes as UTF-8 rather than its default Latin-1; the
NOIMPLIED + NODEFDTD flags tell loadHTML not to wrap
the fragment in implicit html/body elements. */
$wrapper = '<?xml encoding="UTF-8" ?>' .
'<div id="__sanitize_root">' . $html . '</div>';
$dom->loadHTML($wrapper, LIBXML_HTML_NOIMPLIED |
LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous_state);
$root = $dom->getElementById('__sanitize_root');
if ($root === null) {
return '';
}
self::cleanElement($root, $block_remote_images);
$output = '';
foreach ($root->childNodes as $child) {
$output .= $dom->saveHTML($child);
}
return $output;
}
/**
* Pre-pass over the raw HTML to scrub markup oddities that
* survive MIME decoding but confuse libxml. Two patterns are
* targeted; both originate from Outlook-template wrappers
* embedded in newsletters and bulk-mail tooling:
*
* 1. `<!/Wrapper Fix>`-style pseudo-closers: look like an
* SGML comment-open prefix but aren't valid markup. libxml
* treats them as literal text so the would-be opener
* (handled below) never gets closed.
* 2. `<Wrapper Fix>`-style pseudo-openers: an unknown tag
* name followed by a space and bare-word "attributes"
* rather than the real attr=value form. libxml accepts
* these as elements but cleanElement strips unknown tags,
* taking the rest of the row's content with them.
*
* Both are deleted entirely. Real shorthand-boolean syntax
* (e.g. <input disabled>) is preserved because its tag name
* is a known HTML element and the regex below only targets
* the more specific pseudo-pattern.
*
* @param string $html raw HTML before parsing
* @return string $html with the pseudo-markup removed
*/
private static function preNormalize($html)
{
/* drop "<!/Anything>" closers. The leading "<!" looks
like the start of a comment or declaration but the
"/" after means none of the real markup productions
match it, so libxml escapes the whole thing as text. */
$html = preg_replace('/<![\/][^>]*>/', '', $html);
/* drop "<TagName Word ...>" openers where TagName is a
tag the HTML spec doesn't define AND the body of the
tag has a space-separated bare word with no "=" or
quote. The (?!a |b |c |...) negative lookahead would
be tedious; instead we just require the tag-body to
contain no "=" at all, which leaves real shorthand
booleans alone (since those have either no body or a
single boolean attribute name -- our pattern requires
a SPACE inside the body, signalling two-word free
text, which boolean shorthand never has). */
$html = preg_replace(
'/<[A-Za-z][A-Za-z0-9]*\s+[A-Za-z][A-Za-z0-9]*\s*>/',
'', $html);
/* drop meta-charset declarations. The MIME pipeline has
already decoded the body to UTF-8 by the time we get
here; the document head's meta is the SENDER's notion
of the bytes when they composed the message, not what
we are actually holding. libxml's loadHTML consults
the meta and would re-decode the bytes through that
charset -- turning UTF-8 em-dashes and smart quotes
into Latin-1 mojibake when the meta declares (e.g.)
Windows-1252 inside a part the MIME header declared
UTF-8. Outlook 2010 and many enterprise mail systems
emit exactly that combination. Two shapes are
targeted: <meta http-equiv="Content-Type" ...> and
<meta charset=...>. Both case-insensitive. */
$html = preg_replace(
'/<meta\s+[^>]*http-equiv\s*=\s*' .
'["\']?content-type["\']?[^>]*>/i', '', $html);
$html = preg_replace(
'/<meta\s+[^>]*charset\s*=[^>]*>/i', '', $html);
return $html;
}
/**
* Walks an element's children in document order, applying
* tag-level policy: disallowed tags are removed entirely (the
* subtree goes with them); unwrap tags (html, body, head) are
* removed but their children are spliced into the parent in
* the deleted tag's place and become subjects of the same
* walk; allowed tags are kept, their attributes filtered, and
* the walk recurses into their children.
*
* Iteration is index-based against the live childNodes list
* rather than over a pre-captured snapshot, because the
* snapshot would not see children that are spliced in by an
* unwrap step. After a removal or splice, the index does not
* advance so the new node at that position is processed next.
*
* @param \DOMElement $element the subtree root to clean
* @param bool $block_remote_images forwarded to
* cleanAttributes so remote images are marked for
* deferred (lazy, hidden) loading
*/
private static function cleanElement($element,
$block_remote_images)
{
$i = 0;
while ($i < $element->childNodes->length) {
$child = $element->childNodes->item($i);
if (!($child instanceof \DOMElement)) {
$i++;
continue;
}
$tag = strtolower($child->tagName);
if (in_array($tag, self::UNWRAP_TAGS, true)) {
/* splice the unwrap element's children into the
parent in its place; they will be inspected by
continued iteration at the same index. */
while ($child->firstChild) {
$element->insertBefore($child->firstChild,
$child);
}
$element->removeChild($child);
/* do not increment $i: the next child to inspect
has shifted into the current position. */
continue;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
$element->removeChild($child);
/* do not increment $i: the next sibling has
shifted into the current position. */
continue;
}
self::cleanElement($child, $block_remote_images);
self::cleanAttributes($child, $tag,
$block_remote_images);
$i++;
}
}
/**
* Strips every attribute on an allowed element that is not
* also explicitly allowed for its tag (or globally). Also
* filters URL-bearing attributes (href, src) through
* sanitizeUrl so dangerous schemes (javascript:, data:, vbs:)
* are dropped. Every attribute whose name begins with "on"
* is dropped unconditionally as an event handler.
*
* When $block_remote_images is true and the element is an
* img with an http(s) src, the src is left untouched but
* loading="lazy" and a mail-blocked-image class are added,
* so the browser defers fetching the (now hidden) image
* until the reader reveals it. The caller provides a "load
* remote images" affordance that removes the class.
*
* @param \DOMElement $element the element to clean
* @param string $tag the lowercased tag name
* @param bool $block_remote_images whether to mark remote
* images for deferred loading
*/
private static function cleanAttributes($element, $tag,
$block_remote_images)
{
$allowed_global = self::ALLOWED_ATTRS['*'] ?? [];
$allowed_specific = self::ALLOWED_ATTRS[$tag] ?? [];
$allowed = array_merge($allowed_global, $allowed_specific);
$attributes = iterator_to_array($element->attributes);
foreach ($attributes as $attr) {
$name = strtolower($attr->name);
if (str_starts_with($name, 'on')) {
$element->removeAttribute($attr->name);
continue;
}
if (!in_array($name, $allowed, true)) {
$element->removeAttribute($attr->name);
continue;
}
if ($name === 'style') {
$clean_style = self::sanitizeStyle($attr->value);
if ($clean_style === '') {
$element->removeAttribute($attr->name);
} else {
$element->setAttribute($attr->name,
$clean_style);
}
continue;
}
if ($name === 'href' || $name === 'src') {
$clean_url = self::sanitizeUrl($attr->value);
if ($clean_url === null) {
$element->removeAttribute($attr->name);
continue;
}
if ($clean_url !== $attr->value) {
$element->setAttribute($attr->name, $clean_url);
}
}
}
if ($block_remote_images && $tag === 'img') {
$url = $element->getAttribute('src');
if (preg_match('#^https?:#i', $url)) {
$element->setAttribute('loading', 'lazy');
$element->setAttribute('class',
'mail-blocked-image');
/* drop any inline display/visibility the message
set on the image: left in place it would
override the mail-blocked-image rule that hides
it, and a shown blocked image is a fetched one,
defeating the block. */
$style = $element->getAttribute('style');
if ($style !== '') {
$kept = self::sanitizeStyle($style,
['display', 'visibility']);
if ($kept === '') {
$element->removeAttribute('style');
} else {
$element->setAttribute('style', $kept);
}
}
}
}
}
/**
* Returns a sanitised version of a URL, or null when the URL
* should be dropped entirely. Schemes other than http, https,
* and mailto are rejected outright (in particular javascript:
* and data: are rejected, both of which can carry script).
* A URL without a scheme (relative path, fragment anchor) is
* passed through unchanged. Whitespace and control characters
* are stripped from the front before scheme detection so a
* value like " java\tscript:..." cannot sneak past via
* tab-insertion.
*
* @param string $url the raw URL value
* @return string|null cleaned URL, or null to drop
*/
private static function sanitizeUrl($url)
{
/* strip control characters and ASCII whitespace that can
otherwise hide a scheme prefix; preserve the rest of
the URL byte-exactly. */
$cleaned = preg_replace('/[\x00-\x20]+/', '', $url);
if ($cleaned === '') {
return null;
}
/* no colon = no scheme = relative URL = safe. */
if (strpos($cleaned, ':') === false) {
return $url;
}
/* a fragment-only URL (starts with #) also has no
scheme even when colons appear later. */
if ($cleaned[0] === '#') {
return $url;
}
$parts = explode(':', $cleaned, 2);
$scheme = strtolower($parts[0]);
if (in_array($scheme, self::ALLOWED_SCHEMES, true)) {
return $url;
}
return null;
}
/**
* Filters an inline style attribute down to a safe subset.
* Each semicolon-separated declaration is kept only when its
* property is on the safe-property allowlist, its value holds
* none of the hazard substrings (url(, expression(, and so
* on), and -- for width -- the value is a percentage or auto
* rather than a fixed length that would overflow a narrow
* pane. A caller may name extra properties to exclude on top
* of the allowlist; a blocked image excludes display and
* visibility so the message cannot un-hide it.
*
* @param string $style the raw style attribute value
* @param array $exclude property names to drop even when they
* are otherwise on the allowlist
* @return string the kept declarations joined by "; ", or the
* empty string when nothing is kept
*/
private static function sanitizeStyle($style, $exclude = [])
{
$safe = [];
foreach (explode(';', $style) as $declaration) {
$colon = strpos($declaration, ':');
if ($colon === false) {
continue;
}
$property = strtolower(trim(substr($declaration, 0,
$colon)));
$value = trim(substr($declaration, $colon + 1));
if ($property === '' || $value === '') {
continue;
}
if (!in_array($property, self::SAFE_STYLE_PROPERTIES,
true)) {
continue;
}
if (in_array($property, $exclude, true)) {
continue;
}
$lower_value = strtolower($value);
$unsafe = false;
foreach (self::STYLE_VALUE_HAZARDS as $hazard) {
if (strpos($lower_value, $hazard) !== false) {
$unsafe = true;
break;
}
}
if ($unsafe) {
continue;
}
/* a fixed-length width is the usual reason a message
overflows a narrow pane, so width is kept only as a
percentage or auto; max-width is left alone since it
only caps a width, never forces one. */
if ($property === 'width' &&
!preg_match('/^(auto|\d+(\.\d+)?%)$/', $value)) {
continue;
}
$safe[] = $property . ': ' . $value;
}
return implode('; ', $safe);
}
}