mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-10-28 05:58:40 -04:00
Drop PHP 8.1 (EOL December 2025), drop Ubuntu 22.04 (EOL May 2027, and there is now 24.04 and 26.04 LTS). PHP 8.1 is already EOL, and Debian, Alpine, Fedora, Red Hat, Synology have dropped PHP 8.1 already. See https://github.com/FreshRSS/FreshRSS/discussions/5474#discussioncomment-16791937 With PHP 8.6 around the corner and in particular a relevant `FILTER_FLAG_GLOBAL_RANGE` in PHP 8.2+. * https://www.php.net/releases/8.2 * https://www.php.net/filter.constants.validation#constant.filter-flag-global-range * https://www.php.net/class.sensitiveparameter * https://php.watch/versions/8.2/readonly-classes * https://www.php.net/function.ini-parse-quantity Allows updating to PHPUnit 11.x instead of 10.x https://phpunit.de/supported-versions.html Contributes to https://github.com/FreshRSS/FreshRSS/issues/9475 * Add regression test for FILTER_FLAG_GLOBAL_RANGE https://github.com/FreshRSS/FreshRSS/pull/9470#discussion_r4371168720
115 lines
5.7 KiB
PHP
115 lines
5.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
|
|
/**
|
|
* FreshRSS_SimplePieCustom::sanitizeHTML() is the XSS defence applied to all untrusted feed
|
|
* content (entry content, entry/feed descriptions) before it is stored or displayed.
|
|
*/
|
|
final class SimplePieCustomTest extends \PHPUnit\Framework\TestCase {
|
|
|
|
#[\Override]
|
|
protected function setUp(): void {
|
|
if (!FreshRSS_Context::hasSystemConf()) {
|
|
FreshRSS_Context::initSystem();
|
|
}
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenEmptyString_returnsEmptyString(): void {
|
|
self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML(''));
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenPlainText_returnsUnchanged(): void {
|
|
self::assertSame('plain text', FreshRSS_SimplePieCustom::sanitizeHTML('plain text'));
|
|
}
|
|
|
|
#[DataProvider('provideMaliciousHtml')]
|
|
public static function test_sanitizeHTML_whenMaliciousInput_stripsDangerousContent(string $input, string $mustNotContain): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML($input);
|
|
self::assertStringNotContainsString($mustNotContain, $result);
|
|
}
|
|
|
|
/** @return Traversable<string,array{string,string}> */
|
|
public static function provideMaliciousHtml(): Traversable {
|
|
yield 'script tag' => ['<script>alert(1)</script>Hello', '<script'];
|
|
yield 'inline event handler' => ['<img src="x" onerror="alert(1)">', 'onerror'];
|
|
yield 'JavaScript URL' => ['<a href="javascript:alert(1)">click</a>', 'href="javascript:'];
|
|
yield 'style tag' => ['<style>body{display:none}</style>Hello', '<style'];
|
|
yield 'data:text/html URL in link' => ['<a href="data:text/html,<script>alert(1)</script>">click</a>', 'href="data:'];
|
|
yield 'data:text/html URL in iframe' => ['<iframe src="data:text/html,<script>alert(1)</script>"></iframe>', 'src="data:'];
|
|
yield 'data:text/html URL in image' => ['<img src="data:text/html,<script>alert(1)</script>">', 'src="data:'];
|
|
yield 'data:image URL in link' => ['<a href="data:image/png;base64,iVBORw0KGgo=">click</a>', 'href="data:'];
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenSafeHtml_keepsAllowedTags(): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello <b>world</b></p>');
|
|
self::assertSame('<p>Hello <b>world</b></p>', $result);
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenUnsafeAttributeIsRemoved_keepsAllowedTag(): void {
|
|
self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">'));
|
|
self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">', maxLength: 100));
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenMaxLengthIsZeroOrNegative_returnsEmptyString(): void {
|
|
self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 0));
|
|
self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: -1));
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenResultFitsWithinMaxLength_isUnaffected(): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 100);
|
|
self::assertSame('<p>Hello world</p>', $result);
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenUnsafePrefixExceedsMaxLength_keepsSafeText(): void {
|
|
self::assertSame('text', FreshRSS_SimplePieCustom::sanitizeHTML('<script>NOK</script><p>text', maxLength: 5));
|
|
}
|
|
|
|
/**
|
|
* Sanitizing can grow a truncated fragment (e.g. `<p>He` gets sanitized into `<p>He</p>`)
|
|
*/
|
|
#[DataProvider('provideMaxLengthInputs')]
|
|
public static function test_sanitizeHTML_whenMaxLengthForcesReSanitizing_terminatesWithinBound(string $input, int $maxLength): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
|
|
self::assertLessThanOrEqual($maxLength, strlen($result));
|
|
}
|
|
|
|
/** @return Traversable<string,array{string,int}> */
|
|
public static function provideMaxLengthInputs(): Traversable {
|
|
yield 'unclosed tag' => ['<p>Hello world</p>', 5];
|
|
yield 'repeated short tags' => [str_repeat('<b>x</b> ', 50), 20];
|
|
yield 'single-character budget' => ['<p>Hello world</p>', 1];
|
|
}
|
|
|
|
#[DataProvider('provideIncompleteTagsOrEntities')]
|
|
public static function test_sanitizeHTML_Cases(string $input, int $maxLength, string $expected): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
|
|
self::assertLessThanOrEqual($maxLength, strlen($result));
|
|
self::assertSame(trim($expected), trim($result));
|
|
}
|
|
|
|
/** @return Traversable<string,array{string,int,string}> */
|
|
public static function provideIncompleteTagsOrEntities(): Traversable {
|
|
yield 'unclosed tag not fitting' => ['<span>Hello</span> <span>World', 31, '<span>Hello</span>'];
|
|
yield 'unclosed entity' => ['Hello…', 9, 'Hello'];
|
|
yield 'double unclosed tag' => ['<b> <b>x', 10, 'x'];
|
|
yield 'triple unclosed tag' => [' <b><b><b>y', 20, 'y'];
|
|
}
|
|
|
|
/**
|
|
* Inline `data:image/...` URIs are inert in `<img>` and widely used by feeds, so they must survive
|
|
* while `data:` is blocked on navigational/framing attributes (see provideMaliciousHtml()).
|
|
*/
|
|
public static function test_sanitizeHTML_whenDataImageUri_keepsItOnImageElements(): void {
|
|
$png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
self::assertSame('<img src="' . $png . '" alt="">', FreshRSS_SimplePieCustom::sanitizeHTML('<img src="' . $png . '" alt="">'));
|
|
self::assertStringContainsString('poster="' . $png . '"', FreshRSS_SimplePieCustom::sanitizeHTML('<video poster="' . $png . '"></video>'));
|
|
}
|
|
|
|
public static function test_sanitizeHTML_whenDisallowedScheme_prefixesUnsafe(): void {
|
|
$result = FreshRSS_SimplePieCustom::sanitizeHTML('<a href="data:text/html,x">click</a>');
|
|
self::assertStringContainsString('href="unsafe:data:text/html,x"', $result);
|
|
}
|
|
}
|