<?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");
}
}