SimplePieCustomTest.php 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. <?php
  2. declare(strict_types=1);
  3. use PHPUnit\Framework\Attributes\DataProvider;
  4. /**
  5. * FreshRSS_SimplePieCustom::sanitizeHTML() is the XSS defence applied to all untrusted feed
  6. * content (entry content, entry/feed descriptions) before it is stored or displayed.
  7. */
  8. final class SimplePieCustomTest extends \PHPUnit\Framework\TestCase {
  9. public function __construct(string $name) {
  10. parent::__construct($name);
  11. if (!FreshRSS_Context::hasSystemConf()) {
  12. FreshRSS_Context::initSystem();
  13. }
  14. }
  15. public static function test_sanitizeHTML_whenEmptyString_returnsEmptyString(): void {
  16. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML(''));
  17. }
  18. public static function test_sanitizeHTML_whenPlainText_returnsUnchanged(): void {
  19. self::assertSame('plain text', FreshRSS_SimplePieCustom::sanitizeHTML('plain text'));
  20. }
  21. #[DataProvider('provideMaliciousHtml')]
  22. public static function test_sanitizeHTML_whenMaliciousInput_stripsDangerousContent(string $input, string $mustNotContain): void {
  23. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input);
  24. self::assertStringNotContainsString($mustNotContain, $result);
  25. }
  26. /** @return Traversable<string,array{string,string}> */
  27. public static function provideMaliciousHtml(): Traversable {
  28. yield 'script tag' => ['<script>alert(1)</script>Hello', '<script'];
  29. yield 'inline event handler' => ['<img src="x" onerror="alert(1)">', 'onerror'];
  30. yield 'JavaScript URL' => ['<a href="javascript:alert(1)">click</a>', 'href="javascript:'];
  31. yield 'style tag' => ['<style>body{display:none}</style>Hello', '<style'];
  32. yield 'data:text/html URL in link' => ['<a href="data:text/html,<script>alert(1)</script>">click</a>', 'href="data:'];
  33. yield 'data:text/html URL in iframe' => ['<iframe src="data:text/html,<script>alert(1)</script>"></iframe>', 'src="data:'];
  34. yield 'data:text/html URL in image' => ['<img src="data:text/html,<script>alert(1)</script>">', 'src="data:'];
  35. yield 'data:image URL in link' => ['<a href="data:image/png;base64,iVBORw0KGgo=">click</a>', 'href="data:'];
  36. }
  37. public static function test_sanitizeHTML_whenSafeHtml_keepsAllowedTags(): void {
  38. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello <b>world</b></p>');
  39. self::assertSame('<p>Hello <b>world</b></p>', $result);
  40. }
  41. public static function test_sanitizeHTML_whenUnsafeAttributeIsRemoved_keepsAllowedTag(): void {
  42. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">'));
  43. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">', maxLength: 100));
  44. }
  45. public static function test_sanitizeHTML_whenMaxLengthIsZeroOrNegative_returnsEmptyString(): void {
  46. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 0));
  47. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: -1));
  48. }
  49. public static function test_sanitizeHTML_whenResultFitsWithinMaxLength_isUnaffected(): void {
  50. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 100);
  51. self::assertSame('<p>Hello world</p>', $result);
  52. }
  53. public static function test_sanitizeHTML_whenUnsafePrefixExceedsMaxLength_keepsSafeText(): void {
  54. self::assertSame('text', FreshRSS_SimplePieCustom::sanitizeHTML('<script>NOK</script><p>text', maxLength: 5));
  55. }
  56. /**
  57. * Sanitizing can grow a truncated fragment (e.g. `<p>He` gets sanitized into `<p>He</p>`)
  58. */
  59. #[DataProvider('provideMaxLengthInputs')]
  60. public static function test_sanitizeHTML_whenMaxLengthForcesReSanitizing_terminatesWithinBound(string $input, int $maxLength): void {
  61. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  62. self::assertLessThanOrEqual($maxLength, strlen($result));
  63. }
  64. /** @return Traversable<string,array{string,int}> */
  65. public static function provideMaxLengthInputs(): Traversable {
  66. yield 'unclosed tag' => ['<p>Hello world</p>', 5];
  67. yield 'repeated short tags' => [str_repeat('<b>x</b> ', 50), 20];
  68. yield 'single-character budget' => ['<p>Hello world</p>', 1];
  69. }
  70. #[DataProvider('provideIncompleteTagsOrEntities')]
  71. public static function test_sanitizeHTML_Cases(string $input, int $maxLength, string $expected): void {
  72. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  73. self::assertLessThanOrEqual($maxLength, strlen($result));
  74. self::assertSame(trim($expected), trim($result));
  75. }
  76. /** @return Traversable<string,array{string,int,string}> */
  77. public static function provideIncompleteTagsOrEntities(): Traversable {
  78. yield 'unclosed tag not fitting' => ['<span>Hello</span> <span>World', 31, '<span>Hello</span>'];
  79. yield 'unclosed entity' => ['Hello&#8230;', 9, 'Hello'];
  80. yield 'double unclosed tag' => ['<b> <b>x', 10, 'x'];
  81. yield 'triple unclosed tag' => [' <b><b><b>y', 20, 'y'];
  82. }
  83. /**
  84. * Inline `data:image/...` URIs are inert in `<img>` and widely used by feeds, so they must survive
  85. * while `data:` is blocked on navigational/framing attributes (see provideMaliciousHtml()).
  86. */
  87. public static function test_sanitizeHTML_whenDataImageUri_keepsItOnImageElements(): void {
  88. $png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
  89. self::assertSame('<img src="' . $png . '" alt="">', FreshRSS_SimplePieCustom::sanitizeHTML('<img src="' . $png . '" alt="">'));
  90. self::assertStringContainsString('poster="' . $png . '"', FreshRSS_SimplePieCustom::sanitizeHTML('<video poster="' . $png . '"></video>'));
  91. }
  92. public static function test_sanitizeHTML_whenDisallowedScheme_prefixesUnsafe(): void {
  93. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<a href="data:text/html,x">click</a>');
  94. self::assertStringContainsString('href="unsafe:data:text/html,x"', $result);
  95. }
  96. }