httpUtilTest.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. <?php
  2. declare(strict_types=1);
  3. use PHPUnit\Framework\Attributes\DataProvider;
  4. /**
  5. * Tests for FreshRSS_http_Util
  6. */
  7. final class httpUtilTest extends \PHPUnit\Framework\TestCase {
  8. #[DataProvider('provideUrlsIgnoringHttps')]
  9. public function test_compareUrlIgnoringHttps(string $url1, string $url2, bool $expected): void {
  10. self::assertEquals($expected, FreshRSS_http_Util::compareUrlIgnoringHttps($url1, $url2) === 0);
  11. }
  12. #[\Override]
  13. protected function tearDown(): void {
  14. FreshRSS_Context::initSystem(reload: true); // Restore the default internal host allowlist
  15. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  16. $resolveOk->setValue(null, []); // Restore the default empty cache
  17. }
  18. #[DataProvider('provideUrlsForRetryAfter')]
  19. public function test_getRetryAfterFile(string $url1, string $url2, bool $sameFile): void {
  20. $getRetryAfterFile = new ReflectionMethod(FreshRSS_http_Util::class, 'getRetryAfterFile');
  21. self::assertSame($sameFile, $getRetryAfterFile->invoke(null, $url1, '') === $getRetryAfterFile->invoke(null, $url2, ''));
  22. }
  23. /** @return array<string,array{string,string,bool}> */
  24. public static function provideUrlsForRetryAfter(): array {
  25. return [
  26. // A public server waits as a whole, per port
  27. 'public server' => ['https://198.51.100.7/feed1', 'https://198.51.100.7/feed2?a=1&b=2', true],
  28. 'public server, other port' => ['https://198.51.100.7/feed', 'https://198.51.100.7:8443/feed', false],
  29. // A server on a local network waits URL by URL
  30. 'local IP address' => ['http://192.168.1.2/feed1', 'http://192.168.1.2/feed2', false],
  31. 'local domain' => ['http://rss-bridge.lan/?bridge=A', 'http://rss-bridge.lan/?bridge=B', false],
  32. 'local, same URL' => ['http://192.168.1.2/feed', 'http://192.168.1.2/feed', true],
  33. ];
  34. }
  35. #[DataProvider('provideCidrRanges')]
  36. public function test_checkCIDR(string $ip, string $range, bool $expected): void {
  37. $checkCIDR = new ReflectionMethod(FreshRSS_http_Util::class, 'checkCIDR');
  38. self::assertEquals($expected, $checkCIDR->invoke(null, $ip, $range));
  39. }
  40. /** @return list<array{string,string,bool}> */
  41. public static function provideCidrRanges(): array {
  42. return [
  43. // A range without a subnet is a single address
  44. ['192.168.1.1', '192.168.1.1', true],
  45. ['192.168.1.2', '192.168.1.1', false],
  46. ['2001:db8::1', '2001:db8::1', true],
  47. ['2001:db8::2', '2001:db8::1', false],
  48. // An explicit subnet keeps working
  49. ['192.168.1.1', '192.168.1.1/32', true],
  50. ['192.168.1.42', '192.168.1.0/24', true],
  51. ['192.168.2.42', '192.168.1.0/24', false],
  52. ['192.168.1.1', '0.0.0.0/0', true],
  53. ['2001:db8::1', '2001:db8::/32', true],
  54. // Invalid input is still rejected
  55. ['192.168.1.1', '', false],
  56. ['192.168.1.1', '192.168.1.1/', false],
  57. ['192.168.1.1', '192.168.1.1/33', false],
  58. ['192.168.1.1', '192.168.1.1/abc', false],
  59. ['192.168.1.1', 'gateway.localdomain', false],
  60. ['192.168.1.1', '2001:db8::1', false],
  61. ['not-an-ip', '192.168.1.1', false],
  62. ];
  63. }
  64. /** @return list<array{string,string,bool}> */
  65. public static function provideUrlsIgnoringHttps(): array {
  66. return [
  67. // Only the scheme differs → equal
  68. ['http://www.blogger.com/feeds/1/posts', 'https://www.blogger.com/feeds/1/posts', true],
  69. ['https://example.net/feed.xml?a=1&b=2', 'http://example.net/feed.xml?a=1&b=2', true],
  70. ['HTTP://Example.net/Feed', 'https://Example.net/Feed', true],
  71. ['HTTPS://Example.net/Feed', 'http://Example.net/Feed', true],
  72. // Fully identical → equal
  73. ['https://example.net/feed', 'https://example.net/feed', true],
  74. ['', '', true],
  75. // Path differs → not equal (scheme-only tolerance must not hide real mismatches)
  76. ['http://example.net/a', 'https://example.net/b', false],
  77. // Trailing slash is a path difference → not equal
  78. ['http://example.net/', 'https://example.net', false],
  79. // Host differs → not equal
  80. ['http://a.example.net/feed', 'https://b.example.net/feed', false],
  81. // Query differs → not equal
  82. ['https://example.net/feed?a=1', 'http://example.net/feed?a=2', false],
  83. // Non-http(s) schemes are compared as-is
  84. ['ftp://example.net/feed', 'https://example.net/feed', false],
  85. ];
  86. }
  87. public function test_getCurlResolveInfoAcceptsPublicNat64Address(): void {
  88. FreshRSS_Context::initSystem();
  89. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  90. $resolveOk->setValue(null, [
  91. 'example.test' => [
  92. '192.0.66.96',
  93. '64:ff9b::c000:4260',
  94. ],
  95. ]);
  96. self::assertSame(
  97. ['example.test:443:192.0.66.96,[64:ff9b::c000:4260]'],
  98. FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed')
  99. );
  100. }
  101. #[DataProvider('providePrivateSubnets')]
  102. public function test_getCurlResolveInfoRejectsPrivateSubnets(string $ip): void {
  103. FreshRSS_Context::initSystem();
  104. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  105. $resolveOk->setValue(null, ['example.test' => [$ip]]);
  106. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed'));
  107. }
  108. /** @return array<string,array{string}> */
  109. public static function providePrivateSubnets(): array {
  110. return [
  111. // One example per range, ensuring FILTER_FLAG_GLOBAL_RANGE and custom checks are sufficient
  112. '127.0.0.0/8' => ['127.0.0.1'], // RFC1700 (Loopback)
  113. '10.0.0.0/8' => ['10.0.0.5'], // RFC1918
  114. '192.168.0.0/16' => ['192.168.1.5'], // RFC1918
  115. '172.16.0.0/12' => ['172.16.5.5'], // RFC1918
  116. '169.254.0.0/16' => ['169.254.5.5'], // RFC3927
  117. '0.0.0.0/8' => ['0.0.0.5'], // RFC5735
  118. '240.0.0.0/4' => ['240.0.0.5'], // RFC1112
  119. '100.64.0.0/10' => ['100.64.5.5'], // RFC6598 (Shared Address Space of Carrier-Grade NAT)
  120. '::1/128' => ['::1'], // Loopback
  121. 'fc00::/7' => ['fc00::5'], // Unique Local Address
  122. 'fe80::/10' => ['fe80::5'], // Link Local Address
  123. '::ffff:0:0/96' => ['::ffff:10.0.0.5'], // IPv4 translations
  124. '64:ff9b::/96' => ['64:ff9b::a9fe:a9fe'], // RFC6052 (IPv4/IPv6 Translator, NAT64)
  125. '64:ff9b:1::/48' => ['64:ff9b:1::c000:4260'], // RFC8215 (Local-Use IPv4/IPv6 Translation Prefix)
  126. '::/128' => ['::'], // Unspecified address
  127. ];
  128. }
  129. /** Socks schemes default to port 1080, and an IP address proxy produces no resolve entry */
  130. public function test_getCurlResolveInfoForProxyAddress(): void {
  131. FreshRSS_Context::initSystem();
  132. // A proxy on a private address is rejected by default
  133. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  134. // Once the proxy is allowlisted, no resolve override is needed for an IP address
  135. FreshRSS_Context::systemConf()->internal_host_allowlist = ['127.0.0.1:1080'];
  136. self::assertSame([], FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  137. // Other proxy ports need their own entry
  138. FreshRSS_Context::systemConf()->internal_host_allowlist = ['127.0.0.1:8080'];
  139. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  140. self::assertSame([], FreshRSS_http_Util::getCurlResolveInfo('https://127.0.0.1:8080'));
  141. }
  142. /** For a hostname proxy, the resolved IP of the proxy is pinned with a resolve entry, to prevent DNS rebinding */
  143. public function test_getCurlResolveInfoForProxyHostname(): void {
  144. FreshRSS_Context::initSystem();
  145. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  146. $resolveOk->setValue(null, ['proxy.example.test' => ['198.51.100.7']]);
  147. FreshRSS_Context::systemConf()->internal_host_allowlist = ['198.51.100.7:1080'];
  148. self::assertSame(
  149. ['proxy.example.test:1080:198.51.100.7'],
  150. FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080')
  151. );
  152. // Applications must not emit resolve entries for an address that is not guaranteed public.
  153. // TEST-NET-2 is a reserved range, so without an allowlist entry, no entry is produced.
  154. FreshRSS_Context::systemConf()->internal_host_allowlist = [];
  155. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080'));
  156. // A genuinely public address produces an entry without allowlisting
  157. $resolveOk->setValue(null, ['public-proxy.example.test' => ['8.8.8.8']]);
  158. self::assertSame(
  159. ['public-proxy.example.test:1080:8.8.8.8'],
  160. FreshRSS_http_Util::getCurlResolveInfo('socks5h://public-proxy.example.test:1080')
  161. );
  162. // A proxy resolving to a private address is rejected unless allowlisted
  163. $resolveOk->setValue(null, ['proxy.example.test' => ['192.168.1.1']]);
  164. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080'));
  165. FreshRSS_Context::systemConf()->internal_host_allowlist = ['192.168.1.1:1080'];
  166. self::assertSame(
  167. ['proxy.example.test:1080:192.168.1.1'],
  168. FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080')
  169. );
  170. }
  171. }