/ src / library / HtmlSanitizer.php
<?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);
    }
}
X