httpUtilTest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. #[DataProvider('provideCurlProxyValues')]
  13. public function test_sanitizeCurlParamsRejectsMalformedProxy(string $proxy, bool $shouldSurvive): void {
  14. $sanitized = FreshRSS_http_Util::sanitizeCurlParams([CURLOPT_PROXY => $proxy, CURLOPT_PROXYTYPE => CURLPROXY_HTTP]);
  15. if ($shouldSurvive) {
  16. self::assertSame($proxy, $sanitized[CURLOPT_PROXY] ?? null);
  17. } else {
  18. self::assertArrayNotHasKey(CURLOPT_PROXY, $sanitized);
  19. }
  20. }
  21. /** @return array<string,array{string,bool}> */
  22. public static function provideCurlProxyValues(): array {
  23. return [
  24. // A second `://` would otherwise be reduced by a greedy scheme-strip to this
  25. // trailing "decoy" host for the allowlist/DNS-pinning check, while cURL still
  26. // received the original, unstripped value as CURLOPT_PROXY.
  27. 'rejects scheme-prefixed decoy host' => ['http://127.0.0.1:8080/://8.8.8.8', false],
  28. 'rejects scheme-less decoy host' => ['127.0.0.1:8080/://198.51.100.10', false],
  29. // The scheme comes from CURLOPT_PROXYTYPE, not the value itself -- a scheme-containing
  30. // value is rejected outright rather than normalised.
  31. 'rejects a value carrying its own scheme' => ['http://proxy.example.com:3128', false],
  32. // cURL treats "host:port/path" as a Unix domain socket for SOCKS proxies and ignores the
  33. // port, so an allowlisted host:port could otherwise be smuggled into connecting to an
  34. // arbitrary local socket.
  35. 'rejects a SOCKS Unix-domain-socket path' => ['localhost:9050/path/to.sock', false],
  36. 'accepts bare host:port' => ['127.0.0.1:8080', true],
  37. 'accepts hostname:port' => ['proxy.example.com:3128', true],
  38. 'accepts host:port with credentials' => ['user:pass@203.0.113.5:1080', true],
  39. 'accepts an IPv6 proxy' => ['[::1]:8080', true],
  40. ];
  41. }
  42. #[\Override]
  43. protected function tearDown(): void {
  44. FreshRSS_Context::initSystem(reload: true); // Restore the default internal host allowlist
  45. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  46. $resolveOk->setValue(null, []); // Restore the default empty cache
  47. }
  48. #[DataProvider('provideUrlsForRetryAfter')]
  49. public function test_getRetryAfterFile(string $url1, string $url2, bool $sameFile): void {
  50. $getRetryAfterFile = new ReflectionMethod(FreshRSS_http_Util::class, 'getRetryAfterFile');
  51. self::assertSame($sameFile, $getRetryAfterFile->invoke(null, $url1, '') === $getRetryAfterFile->invoke(null, $url2, ''));
  52. }
  53. /** @return array<string,array{string,string,bool}> */
  54. public static function provideUrlsForRetryAfter(): array {
  55. return [
  56. // A public server waits as a whole, per port
  57. 'public server' => ['https://198.51.100.7/feed1', 'https://198.51.100.7/feed2?a=1&b=2', true],
  58. 'public server, other port' => ['https://198.51.100.7/feed', 'https://198.51.100.7:8443/feed', false],
  59. // A server on a local network waits URL by URL
  60. 'local IP address' => ['http://192.168.1.2/feed1', 'http://192.168.1.2/feed2', false],
  61. 'local domain' => ['http://rss-bridge.lan/?bridge=A', 'http://rss-bridge.lan/?bridge=B', false],
  62. 'local, same URL' => ['http://192.168.1.2/feed', 'http://192.168.1.2/feed', true],
  63. ];
  64. }
  65. #[DataProvider('provideUtf8Html')]
  66. public function test_enforceHtmlBase_keepsUtf8InScripts(string $html): void {
  67. $enforceHtmlBase = new ReflectionMethod(FreshRSS_http_Util::class, 'enforceHtmlBase');
  68. $result = $enforceHtmlBase->invoke(null, $html, 'https://example.net/page');
  69. self::assertIsString($result);
  70. self::assertStringContainsString('<base href="https://example.net/page">', $result);
  71. self::assertStringContainsString('<script type="application/json">{"title":"Café’s menu — 日本"}</script>', $result);
  72. if (str_starts_with($html, "\xEF\xBB\xBF")) {
  73. self::assertStringStartsWith("\xEF\xBB\xBF", $result);
  74. }
  75. }
  76. /** @return Traversable<string,array{string}> */
  77. public static function provideUtf8Html(): Traversable {
  78. $head = '<html><head><title>T</title></head>';
  79. $body = '<body><p>Café’s menu — 日本</p><script type="application/json">{"title":"Café’s menu — 日本"}</script></body></html>';
  80. yield 'meta charset' => ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>T</title></head>' . $body];
  81. yield 'byte order mark' => ["\xEF\xBB\xBF" . '<!DOCTYPE html>' . $head . $body];
  82. yield 'byte order mark, late <html>' => ["\xEF\xBB\xBF" . '<!DOCTYPE html>' . str_repeat('<!-- padding -->', 40) . $head . $body];
  83. yield 'meta http-equiv' => ['<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><title>T</title></head>' . $body];
  84. }
  85. #[DataProvider('provideCidrRanges')]
  86. public function test_checkCIDR(string $ip, string $range, bool $expected): void {
  87. $checkCIDR = new ReflectionMethod(FreshRSS_http_Util::class, 'checkCIDR');
  88. self::assertEquals($expected, $checkCIDR->invoke(null, $ip, $range));
  89. }
  90. /** @return list<array{string,string,bool}> */
  91. public static function provideCidrRanges(): array {
  92. return [
  93. // A range without a subnet is a single address
  94. ['192.168.1.1', '192.168.1.1', true],
  95. ['192.168.1.2', '192.168.1.1', false],
  96. ['2001:db8::1', '2001:db8::1', true],
  97. ['2001:db8::2', '2001:db8::1', false],
  98. // An explicit subnet keeps working
  99. ['192.168.1.1', '192.168.1.1/32', true],
  100. ['192.168.1.42', '192.168.1.0/24', true],
  101. ['192.168.2.42', '192.168.1.0/24', false],
  102. ['192.168.1.1', '0.0.0.0/0', true],
  103. ['2001:db8::1', '2001:db8::/32', true],
  104. // Invalid input is still rejected
  105. ['192.168.1.1', '', false],
  106. ['192.168.1.1', '192.168.1.1/', false],
  107. ['192.168.1.1', '192.168.1.1/33', false],
  108. ['192.168.1.1', '192.168.1.1/abc', false],
  109. ['192.168.1.1', 'gateway.localdomain', false],
  110. ['192.168.1.1', '2001:db8::1', false],
  111. ['not-an-ip', '192.168.1.1', false],
  112. ];
  113. }
  114. /** @return list<array{string,string,bool}> */
  115. public static function provideUrlsIgnoringHttps(): array {
  116. return [
  117. // Only the scheme differs → equal
  118. ['http://www.blogger.com/feeds/1/posts', 'https://www.blogger.com/feeds/1/posts', true],
  119. ['https://example.net/feed.xml?a=1&b=2', 'http://example.net/feed.xml?a=1&b=2', true],
  120. ['HTTP://Example.net/Feed', 'https://Example.net/Feed', true],
  121. ['HTTPS://Example.net/Feed', 'http://Example.net/Feed', true],
  122. // Fully identical → equal
  123. ['https://example.net/feed', 'https://example.net/feed', true],
  124. ['', '', true],
  125. // Path differs → not equal (scheme-only tolerance must not hide real mismatches)
  126. ['http://example.net/a', 'https://example.net/b', false],
  127. // Trailing slash is a path difference → not equal
  128. ['http://example.net/', 'https://example.net', false],
  129. // Host differs → not equal
  130. ['http://a.example.net/feed', 'https://b.example.net/feed', false],
  131. // Query differs → not equal
  132. ['https://example.net/feed?a=1', 'http://example.net/feed?a=2', false],
  133. // Non-http(s) schemes are compared as-is
  134. ['ftp://example.net/feed', 'https://example.net/feed', false],
  135. ];
  136. }
  137. public function test_getCurlResolveInfoAcceptsPublicNat64Address(): void {
  138. FreshRSS_Context::initSystem();
  139. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  140. $resolveOk->setValue(null, [
  141. 'example.test' => [
  142. '192.0.66.96',
  143. '64:ff9b::c000:4260',
  144. ],
  145. ]);
  146. self::assertSame(
  147. ['example.test:443:192.0.66.96,[64:ff9b::c000:4260]'],
  148. FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed')
  149. );
  150. }
  151. #[DataProvider('providePrivateSubnets')]
  152. public function test_getCurlResolveInfoRejectsPrivateSubnets(string $ip): void {
  153. FreshRSS_Context::initSystem();
  154. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  155. $resolveOk->setValue(null, ['example.test' => [$ip]]);
  156. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed'));
  157. }
  158. /** @return array<string,array{string}> */
  159. public static function providePrivateSubnets(): array {
  160. return [
  161. // One example per range, ensuring FILTER_FLAG_GLOBAL_RANGE and custom checks are sufficient
  162. '127.0.0.0/8' => ['127.0.0.1'], // RFC1700 (Loopback)
  163. '10.0.0.0/8' => ['10.0.0.5'], // RFC1918
  164. '192.168.0.0/16' => ['192.168.1.5'], // RFC1918
  165. '172.16.0.0/12' => ['172.16.5.5'], // RFC1918
  166. '169.254.0.0/16' => ['169.254.5.5'], // RFC3927
  167. '0.0.0.0/8' => ['0.0.0.5'], // RFC5735
  168. '240.0.0.0/4' => ['240.0.0.5'], // RFC1112
  169. '100.64.0.0/10' => ['100.64.5.5'], // RFC6598 (Shared Address Space of Carrier-Grade NAT)
  170. '::1/128' => ['::1'], // Loopback
  171. 'fc00::/7' => ['fc00::5'], // Unique Local Address
  172. 'fe80::/10' => ['fe80::5'], // Link Local Address
  173. '::ffff:0:0/96' => ['::ffff:10.0.0.5'], // IPv4 translations
  174. '64:ff9b::/96' => ['64:ff9b::a9fe:a9fe'], // RFC6052 (IPv4/IPv6 Translator, NAT64)
  175. '64:ff9b:1::/48' => ['64:ff9b:1::c000:4260'], // RFC8215 (Local-Use IPv4/IPv6 Translation Prefix)
  176. '::/128' => ['::'], // Unspecified address
  177. ];
  178. }
  179. /** Socks schemes default to port 1080, and an IP address proxy produces no resolve entry */
  180. public function test_getCurlResolveInfoForProxyAddress(): void {
  181. FreshRSS_Context::initSystem();
  182. // A proxy on a private address is rejected by default
  183. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  184. // Once the proxy is allowlisted, no resolve override is needed for an IP address
  185. FreshRSS_Context::systemConf()->internal_host_allowlist = ['127.0.0.1:1080'];
  186. self::assertSame([], FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  187. // Other proxy ports need their own entry
  188. FreshRSS_Context::systemConf()->internal_host_allowlist = ['127.0.0.1:8080'];
  189. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://127.0.0.1:1080'));
  190. self::assertSame([], FreshRSS_http_Util::getCurlResolveInfo('https://127.0.0.1:8080'));
  191. }
  192. /** For a hostname proxy, the resolved IP of the proxy is pinned with a resolve entry, to prevent DNS rebinding */
  193. public function test_getCurlResolveInfoForProxyHostname(): void {
  194. FreshRSS_Context::initSystem();
  195. $resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
  196. $resolveOk->setValue(null, ['proxy.example.test' => ['198.51.100.7']]);
  197. FreshRSS_Context::systemConf()->internal_host_allowlist = ['198.51.100.7:1080'];
  198. self::assertSame(
  199. ['proxy.example.test:1080:198.51.100.7'],
  200. FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080')
  201. );
  202. // Applications must not emit resolve entries for an address that is not guaranteed public.
  203. // TEST-NET-2 is a reserved range, so without an allowlist entry, no entry is produced.
  204. FreshRSS_Context::systemConf()->internal_host_allowlist = [];
  205. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080'));
  206. // A genuinely public address produces an entry without allowlisting
  207. $resolveOk->setValue(null, ['public-proxy.example.test' => ['8.8.8.8']]);
  208. self::assertSame(
  209. ['public-proxy.example.test:1080:8.8.8.8'],
  210. FreshRSS_http_Util::getCurlResolveInfo('socks5h://public-proxy.example.test:1080')
  211. );
  212. // A proxy resolving to a private address is rejected unless allowlisted
  213. $resolveOk->setValue(null, ['proxy.example.test' => ['192.168.1.1']]);
  214. self::assertNull(FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080'));
  215. FreshRSS_Context::systemConf()->internal_host_allowlist = ['192.168.1.1:1080'];
  216. self::assertSame(
  217. ['proxy.example.test:1080:192.168.1.1'],
  218. FreshRSS_http_Util::getCurlResolveInfo('socks5h://proxy.example.test:1080')
  219. );
  220. }
  221. }