SimplePieCustomTest.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  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. #[\Override]
  10. public static function setUpBeforeClass(): void {
  11. FreshRSS_Context::initSystem();
  12. }
  13. public static function test_sanitizeHTML_whenEmptyString_returnsEmptyString(): void {
  14. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML(''));
  15. }
  16. public static function test_sanitizeHTML_whenPlainText_returnsUnchanged(): void {
  17. self::assertSame('plain text', FreshRSS_SimplePieCustom::sanitizeHTML('plain text'));
  18. }
  19. #[DataProvider('provideMaliciousHtml')]
  20. public static function test_sanitizeHTML_whenMaliciousInput_stripsDangerousContent(string $input, string $mustNotContain): void {
  21. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input);
  22. self::assertStringNotContainsString($mustNotContain, $result);
  23. }
  24. /** @return Traversable<string,array{string,string}> */
  25. public static function provideMaliciousHtml(): Traversable {
  26. yield 'script tag' => ['<script>alert(1)</script>Hello', '<script'];
  27. yield 'inline event handler' => ['<img src="x" onerror="alert(1)">', 'onerror'];
  28. yield 'JavaScript URL' => ['<a href="javascript:alert(1)">click</a>', 'href="javascript:'];
  29. yield 'style tag' => ['<style>body{display:none}</style>Hello', '<style'];
  30. }
  31. public static function test_sanitizeHTML_whenSafeHtml_keepsAllowedTags(): void {
  32. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello <b>world</b></p>');
  33. self::assertSame('<p>Hello <b>world</b></p>', $result);
  34. }
  35. public static function test_sanitizeHTML_whenUnsafeAttributeIsRemoved_keepsAllowedTag(): void {
  36. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">'));
  37. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">', maxLength: 100));
  38. }
  39. public static function test_sanitizeHTML_whenMaxLengthIsZeroOrNegative_returnsEmptyString(): void {
  40. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 0));
  41. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: -1));
  42. }
  43. public static function test_sanitizeHTML_whenResultFitsWithinMaxLength_isUnaffected(): void {
  44. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 100);
  45. self::assertSame('<p>Hello world</p>', $result);
  46. }
  47. public static function test_sanitizeHTML_whenUnsafePrefixExceedsMaxLength_keepsSafeText(): void {
  48. self::assertSame('text', FreshRSS_SimplePieCustom::sanitizeHTML('<script>NOK</script><p>text', maxLength: 5));
  49. }
  50. /**
  51. * Sanitizing can grow a truncated fragment (e.g. `<p>He` gets sanitized into `<p>He</p>`)
  52. */
  53. #[DataProvider('provideMaxLengthInputs')]
  54. public static function test_sanitizeHTML_whenMaxLengthForcesReSanitizing_terminatesWithinBound(string $input, int $maxLength): void {
  55. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  56. self::assertLessThanOrEqual($maxLength, strlen($result));
  57. }
  58. /** @return Traversable<string,array{string,int}> */
  59. public static function provideMaxLengthInputs(): Traversable {
  60. yield 'unclosed tag' => ['<p>Hello world</p>', 5];
  61. yield 'repeated short tags' => [str_repeat('<b>x</b> ', 50), 20];
  62. yield 'single-character budget' => ['<p>Hello world</p>', 1];
  63. }
  64. #[DataProvider('provideIncompleteTagsOrEntities')]
  65. public static function test_sanitizeHTML_Cases(string $input, int $maxLength, string $expected): void {
  66. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  67. self::assertLessThanOrEqual($maxLength, strlen($result));
  68. self::assertSame(trim($expected), trim($result));
  69. }
  70. /** @return Traversable<string,array{string,int,string}> */
  71. public static function provideIncompleteTagsOrEntities(): Traversable {
  72. yield 'unclosed tag not fitting' => ['<span>Hello</span> <span>World', 31, '<span>Hello</span>'];
  73. yield 'unclosed entity' => ['Hello&#8230;', 9, 'Hello'];
  74. yield 'double unclosed tag' => ['<b> <b>x', 10, 'x'];
  75. yield 'triple unclosed tag' => [' <b><b><b>y', 20, 'y'];
  76. }
  77. }