SimplePieCustomTest.php 4.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  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. }
  33. public static function test_sanitizeHTML_whenSafeHtml_keepsAllowedTags(): void {
  34. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello <b>world</b></p>');
  35. self::assertSame('<p>Hello <b>world</b></p>', $result);
  36. }
  37. public static function test_sanitizeHTML_whenUnsafeAttributeIsRemoved_keepsAllowedTag(): void {
  38. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">'));
  39. self::assertSame('Hello <br>', FreshRSS_SimplePieCustom::sanitizeHTML('Hello <br onclick="x">', maxLength: 100));
  40. }
  41. public static function test_sanitizeHTML_whenMaxLengthIsZeroOrNegative_returnsEmptyString(): void {
  42. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 0));
  43. self::assertSame('', FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: -1));
  44. }
  45. public static function test_sanitizeHTML_whenResultFitsWithinMaxLength_isUnaffected(): void {
  46. $result = FreshRSS_SimplePieCustom::sanitizeHTML('<p>Hello world</p>', maxLength: 100);
  47. self::assertSame('<p>Hello world</p>', $result);
  48. }
  49. public static function test_sanitizeHTML_whenUnsafePrefixExceedsMaxLength_keepsSafeText(): void {
  50. self::assertSame('text', FreshRSS_SimplePieCustom::sanitizeHTML('<script>NOK</script><p>text', maxLength: 5));
  51. }
  52. /**
  53. * Sanitizing can grow a truncated fragment (e.g. `<p>He` gets sanitized into `<p>He</p>`)
  54. */
  55. #[DataProvider('provideMaxLengthInputs')]
  56. public static function test_sanitizeHTML_whenMaxLengthForcesReSanitizing_terminatesWithinBound(string $input, int $maxLength): void {
  57. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  58. self::assertLessThanOrEqual($maxLength, strlen($result));
  59. }
  60. /** @return Traversable<string,array{string,int}> */
  61. public static function provideMaxLengthInputs(): Traversable {
  62. yield 'unclosed tag' => ['<p>Hello world</p>', 5];
  63. yield 'repeated short tags' => [str_repeat('<b>x</b> ', 50), 20];
  64. yield 'single-character budget' => ['<p>Hello world</p>', 1];
  65. }
  66. #[DataProvider('provideIncompleteTagsOrEntities')]
  67. public static function test_sanitizeHTML_Cases(string $input, int $maxLength, string $expected): void {
  68. $result = FreshRSS_SimplePieCustom::sanitizeHTML($input, maxLength: $maxLength);
  69. self::assertLessThanOrEqual($maxLength, strlen($result));
  70. self::assertSame(trim($expected), trim($result));
  71. }
  72. /** @return Traversable<string,array{string,int,string}> */
  73. public static function provideIncompleteTagsOrEntities(): Traversable {
  74. yield 'unclosed tag not fitting' => ['<span>Hello</span> <span>World', 31, '<span>Hello</span>'];
  75. yield 'unclosed entity' => ['Hello&#8230;', 9, 'Hello'];
  76. yield 'double unclosed tag' => ['<b> <b>x', 10, 'x'];
  77. yield 'triple unclosed tag' => [' <b><b><b>y', 20, 'y'];
  78. }
  79. }