/ tests / HtmlSanitizerTest.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\tests;

use seekquarry\yioop\library\HtmlSanitizer;
use seekquarry\yioop\library\UnitTest;

/**
 * Unit tests for HtmlSanitizer. Each case feeds a fragment of
 * (possibly malicious) HTML and checks that the sanitiser
 * preserves what should pass and removes what should not.
 *
 * @author Chris Pollett
 */
class HtmlSanitizerTest extends UnitTest
{
    /**
     * No setup state is needed; the sanitiser is pure and static.
     */
    public function setUp()
    {
    }
    /**
     * No setup state is needed; nothing to tear down.
     */
    public function tearDown()
    {
    }
    /**
     * Bold and italic text is preserved verbatim.
     */
    public function basicFormattingPreservedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<p>Hello <strong>world</strong> and " .
            "<em>others</em>.</p>");
        $this->assertTrue(
            strpos($output, "<strong>world</strong>") !== false,
            "strong preserved");
        $this->assertTrue(
            strpos($output, "<em>others</em>") !== false,
            "em preserved");
    }
    /**
     * A script tag and its content are removed entirely. The
     * surrounding paragraph survives.
     */
    public function scriptTagStrippedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<p>safe</p><script>alert('xss')</script>" .
            "<p>also safe</p>");
        $this->assertTrue(strpos($output, "<script") === false,
            "script tag removed");
        $this->assertTrue(strpos($output, "alert") === false,
            "script body text removed too");
        $this->assertTrue(strpos($output, "safe") !== false,
            "surrounding paragraphs survive");
    }
    /**
     * A style tag is removed; CSS in the body cannot beacon to a
     * tracker pixel.
     */
    public function styleTagStrippedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<style>body{background:url(http://tracker/p.gif)}" .
            "</style><p>Hi</p>");
        $this->assertTrue(strpos($output, "<style") === false,
            "style tag removed");
        $this->assertTrue(strpos($output, "tracker") === false,
            "CSS body removed");
    }
    /**
     * An iframe is removed; framed cross-origin content cannot
     * load inside the message view.
     */
    public function iframeStrippedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<p>before</p><iframe src='http://evil/'></iframe>" .
            "<p>after</p>");
        $this->assertTrue(strpos($output, "<iframe") === false,
            "iframe tag removed");
        $this->assertTrue(strpos($output, "evil") === false,
            "src attribute gone with the iframe");
    }
    /**
     * An onclick attribute is removed; the surrounding tag and
     * other safe attributes survive.
     */
    public function onclickAttributeStrippedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href='https://example.com' " .
            "onclick='steal()' title='ok'>link</a>");
        $this->assertTrue(strpos($output, "onclick") === false,
            "onclick removed");
        $this->assertTrue(strpos($output,
            'href="https://example.com"') !== false,
            "valid href preserved");
        $this->assertTrue(strpos($output, 'title="ok"') !== false,
            "title preserved");
    }
    /**
     * A javascript: URL in an href is dropped; the anchor tag
     * survives but with no href attribute. This way the link
     * text remains visible but the click does nothing dangerous.
     */
    public function javascriptHrefDroppedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href=\"javascript:alert('xss')\">click me</a>");
        $this->assertTrue(strpos($output, "javascript") === false,
            "javascript: scheme removed");
        $this->assertTrue(strpos($output, "click me") !== false,
            "anchor text survives");
    }
    /**
     * A data: URL is also dropped; data: can carry script in
     * data:text/html,<script>... and is a well-known XSS vector.
     */
    public function dataHrefDroppedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href='data:text/html,<script>...</script>'" .
            ">click</a>");
        $this->assertTrue(strpos($output, "data:") === false,
            "data: scheme removed");
    }
    /**
     * A scheme hidden behind tab characters (an old XSS trick)
     * is still detected and dropped. "java\tscript:" used to
     * sneak past naive scheme parsers because the browser would
     * strip the tab and execute the script.
     */
    public function obfuscatedSchemeDroppedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href=\"java\tscript:alert(1)\">click</a>");
        $this->assertTrue(stripos($output, "script:") === false,
            "tab-obfuscated javascript scheme removed");
    }
    /**
     * A mailto: URL is preserved (allowed scheme).
     */
    public function mailtoHrefPreservedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href='mailto:test@example.com'>mail</a>");
        $this->assertTrue(
            strpos($output, 'href="mailto:test@example.com"') !==
            false, "mailto preserved");
    }
    /**
     * A relative URL (no scheme) is preserved -- legitimate use
     * for fragment anchors and relative paths within an email
     * (rare but valid).
     */
    public function relativeHrefPreservedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<a href='#section1'>section</a>");
        $this->assertTrue(
            strpos($output, 'href="#section1"') !== false,
            "fragment anchor preserved");
    }
    /**
     * An image with an http src is preserved (the privacy
     * tradeoff of remote-image rendering is accepted in v1; the
     * sanitiser stops at the security boundary).
     */
    public function imgPreservedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<img src='https://example.com/x.png' alt='X' " .
            "width='100'>");
        $this->assertTrue(strpos($output, "<img") !== false,
            "img tag preserved");
        $this->assertTrue(strpos($output,
            'src="https://example.com/x.png"') !== false,
            "src preserved");
        $this->assertTrue(strpos($output, 'alt="X"') !== false,
            "alt preserved");
    }
    /**
     * A table with thead/tbody/tr/td and colspan attribute is
     * preserved -- needed for newsletters and structured email.
     */
    public function tablePreservedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<table><thead><tr><th colspan='2'>Hi</th>" .
            "</tr></thead><tbody><tr><td>A</td><td>B</td>" .
            "</tr></tbody></table>");
        $this->assertTrue(strpos($output, "<table>") !== false,
            "table preserved");
        $this->assertTrue(strpos($output, 'colspan="2"') !== false,
            "colspan preserved");
        $this->assertTrue(strpos($output, "<td>A</td>") !== false,
            "td preserved");
    }
    /**
     * A safe inline style declaration is kept so formatted mail
     * renders with its colours and spacing, while a hazardous one
     * is dropped: position could lift content over the page, and a
     * url() value would beacon a tracker and slip past the
     * remote-image block.
     */
    public function styleAttributeFilteredTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<p style=\"color:#333;position:absolute;" .
            "background:url(http://tracker/p.gif)\">hidden " .
            "text</p>");
        $this->assertTrue(strpos($output, "#333") !== false,
            "safe colour declaration kept");
        $this->assertTrue(strpos($output, "position") === false,
            "position declaration dropped");
        $this->assertTrue(strpos($output, "tracker") === false,
            "url() tracker value dropped");
        $this->assertTrue(strpos($output, "hidden text") !== false,
            "text content preserved");
    }
    /**
     * A blocked remote image keeps no inline display declaration:
     * left in place it would override the rule that hides the
     * image, and a shown blocked image is a fetched one.
     */
    public function blockedImageDisplayStrippedTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<img src=\"http://tracker/p.gif\" " .
            "style=\"display:block;color:#000\">", true);
        $this->assertTrue(
            strpos($output, "mail-blocked-image") !== false,
            "remote image is marked blocked");
        $this->assertTrue(strpos($output, "display") === false,
            "inline display dropped on a blocked image");
    }
    /**
     * A fixed-pixel width is dropped (it would overflow a narrow
     * reading pane) while a percentage width and a max-width are
     * kept so fluid and capped layouts survive.
     */
    public function fixedWidthFilteredTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<table style=\"width:600px\"><tr><td " .
            "style=\"width:50%;max-width:300px\">x</td></tr>" .
            "</table>");
        $this->assertTrue(strpos($output, "600px") === false,
            "fixed pixel width dropped");
        $this->assertTrue(strpos($output, "50%") !== false,
            "percentage width kept");
        $this->assertTrue(strpos($output, "max-width") !== false,
            "max-width kept");
    }
    /**
     * Empty input returns the empty string without error.
     */
    public function emptyInputTestCase()
    {
        $this->assertEqual("", HtmlSanitizer::sanitize(""),
            "empty input is empty output");
    }
    /**
     * A real-world phishing-style HTML body: a hidden script,
     * a tracker image, and a deceptive link. Verifies the
     * tracker survives only as an img with src (no script, no
     * iframe), the script is gone, the deceptive link still
     * exists (we cannot tell legitimate links from deceptive
     * ones in general -- that's a user-judgment problem) but
     * its onclick is gone.
     */
    public function realisticPhishingFragmentTestCase()
    {
        $input = "<p>Dear Customer,</p>" .
            "<p>Please <a href='https://example.com/login' " .
            "onclick='steal()'>verify your account</a>.</p>" .
            "<script>document.location='http://evil/'</script>" .
            "<img src='https://tracker/beacon.gif' " .
            "width='1' height='1'>";
        $output = HtmlSanitizer::sanitize($input);
        $this->assertTrue(strpos($output, "<script") === false,
            "no script");
        $this->assertTrue(strpos($output, "onclick") === false,
            "no onclick");
        $this->assertTrue(strpos($output,
            'href="https://example.com/login"') !== false,
            "anchor still present (user must judge)");
        $this->assertTrue(strpos($output, "<img") !== false,
            "img still present (privacy is a v2 concern)");
    }
    /**
     * A full HTML document (with doctype, html, head, body) must
     * unwrap: the html/body/head wrappers go but their content
     * survives. head's contents (style, meta, link) get dropped
     * separately by the disallowed-tag rule.
     */
    public function fullHtmlDocumentUnwrapsTestCase()
    {
        $input = "<!doctype html><html><head>" .
            "<title>Order</title>" .
            "<style>body{color:red}</style>" .
            "<meta charset='utf-8'>" .
            "</head><body>" .
            "<p>Order confirmation</p>" .
            "<p>Total: <strong>$395</strong></p>" .
            "</body></html>";
        $output = HtmlSanitizer::sanitize($input);
        $this->assertTrue(strpos($output, "<html") === false,
            "html wrapper removed");
        $this->assertTrue(strpos($output, "<body") === false,
            "body wrapper removed");
        $this->assertTrue(strpos($output, "<head") === false,
            "head wrapper removed");
        $this->assertTrue(strpos($output, "<style") === false,
            "style inside head removed");
        $this->assertTrue(strpos($output, "<meta") === false,
            "meta inside head removed");
        $this->assertTrue(strpos($output, "<title") === false,
            "title removed");
        $this->assertTrue(strpos($output, "Order confirmation") !==
            false, "body content survives");
        $this->assertTrue(strpos($output, "<strong>") !== false,
            "body inline tags survive");
    }
    /**
     * When block_remote_images is true, an http/https img keeps
     * its src but is marked so the browser does not fetch it on
     * render: loading="lazy" and a mail-blocked-image class are
     * added (the class hides the image, so the lazy image is never
     * near the viewport and is not fetched). The img stays in the
     * output and alt and width are preserved.
     */
    public function remoteImageBlockingTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<p>See: <img src='https://tracker/p.gif' " .
            "alt='X' width='100'></p>", true);
        $this->assertTrue(
            strpos($output, 'src="https://tracker/p.gif"')
            !== false, "src kept on a blocked image");
        $this->assertTrue(
            strpos($output, "mail-blocked-image") !== false,
            "blocked image carries the hiding class");
        $this->assertTrue(strpos($output, 'loading="lazy"')
            !== false, "blocked image is marked lazy");
        $this->assertTrue(strpos($output, 'alt="X"') !== false,
            "alt preserved");
        $this->assertTrue(strpos($output, 'width="100"') !== false,
            "width preserved");
    }
    /**
     * When block_remote_images is false (default), img src is
     * left in place and the image is not marked as blocked.
     */
    public function remoteImageBlockingOffByDefaultTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<img src='https://tracker/p.gif'>");
        $this->assertTrue(strpos($output,
            'src="https://tracker/p.gif"') !== false,
            "src preserved when block_remote_images is false");
        $this->assertTrue(
            strpos($output, "mail-blocked-image") === false,
            "no blocking class when not blocking");
    }
    /**
     * A center wrapper is unwrapped, not removed with its subtree.
     * HTML mail commonly wraps the entire body in a single
     * <center>; treating it as a disallowed tag would discard the
     * whole message and render the webmail view blank.
     */
    public function centerWrapperUnwrapsTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<center style=\"width:100%\"><table><tr><td>" .
            "<p>Welcome to the message</p></td></tr></table>" .
            "</center>");
        $this->assertTrue(strpos($output, "<center") === false,
            "center tag itself is removed");
        $this->assertTrue(
            strpos($output, "Welcome to the message") !== false,
            "content inside center survives");
        $this->assertTrue(strpos($output, "<table") !== false,
            "table structure inside center survives");
    }
    /**
     * A font wrapper is likewise unwrapped rather than dropped
     * with its content, and a script inside a center wrapper is
     * still stripped so unwrapping does not weaken safety.
     */
    public function fontUnwrapsAndCenterKeepsSafeTestCase()
    {
        $output = HtmlSanitizer::sanitize(
            "<font color=\"red\"><p>kept text</p></font>");
        $this->assertTrue(strpos($output, "<font") === false,
            "font tag itself is removed");
        $this->assertTrue(strpos($output, "kept text") !== false,
            "content inside font survives");
        $with_script = HtmlSanitizer::sanitize(
            "<center><p>safe text</p>" .
            "<script>alert('xss')</script></center>");
        $this->assertTrue(strpos($with_script, "safe text") !== false,
            "content inside center survives alongside a script");
        $this->assertTrue(strpos($with_script, "alert") === false,
            "script inside an unwrapped center is still stripped");
    }
}
X