Files
FreshRSS/tests/app/Models/SimplePieCustomTest.php
T
0187b15c8e Sanitizer: block data: URI scheme while keeping data:image for images (#9417)
* Sanitizer: block data: URI scheme, keep data:image for images

FreshRSS_SimplePieCustom only disallowed the `javascript` scheme, so
`data:text/html,...` URIs in feed content survived
sanitization on <a href>, <iframe src>, <audio|video|source|track src>.
The default CSP neutralises these on FreshRSS pages, but clients and
deployments without that CSP (API WebViews, extensions using amendCsp(),
proxies stripping headers) could execute them. Same class as
GHSA-3fw6-j8m4-82vj / #9272.

- Sanitize::is_allowed_uri(): element/attribute-aware check that still
  permits inert `data:image/...` on img[src], image[src], video[poster]
  while blocking `data:` elsewhere; replace_urls() now uses it.
  is_allowed_scheme() is unchanged for backward compatibility.
- SimplePieCustom: disallow_uri_schemes(['javascript','data']).
- Tests: 4 blocked-input cases, a data:image keep-alive test and an
  `unsafe:` prefix test. They fail without the patch and pass with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Sync SimplePie
https://github.com/FreshRSS/simplepie/pull/92

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
2026-10-11 20:36:09 +02:00

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 {
public function __construct(string $name) {
parent::__construct($name);
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&#8230;', 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);
}
}