Przeglądaj źródła

Fix NAT64 feed fetching on IPv6-only hosts (#9372)

* Fix NAT64 feed fetching on IPv6-only hosts

Closes #9371
Regression from https://github.com/FreshRSS/FreshRSS/pull/9195

Changes proposed in this pull request:

* Fix feed fetching on IPv6-only hosts using NAT64.
* Preserve SSRF protection by validating the IPv4 address embedded in RFC 6052 NAT64 addresses before allowing it in `CURLOPT_RESOLVE`.
* Add regression tests for both public and private/reserved NAT64 addresses.

* Minor formating

* Add test tearDown

* RFC8215 64:ff9b:1::/48
Credits: @onurcangnc

---------

Co-authored-by: Viktor Platov <TapuGitHub@users.noreply.github.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Viktor 21 godzin temu
rodzic
commit
d5e13066bc
2 zmienionych plików z 82 dodań i 1 usunięć
  1. 30 0
      app/Utils/httpUtil.php
  2. 52 1
      tests/app/Utils/httpUtilTest.php

+ 30 - 0
app/Utils/httpUtil.php

@@ -18,6 +18,7 @@ final class FreshRSS_http_Util {
 		'fe80::/10',      // Link Local Address
 		'::ffff:0:0/96',  // IPv4 translations
 		'64:ff9b::/96',   // RFC6052 (IPv6 Addressing of IPv4/IPv6 Translators, NAT64)
+		'64:ff9b:1::/48', // RFC8215 (Local-Use IPv4/IPv6 Translation Prefix)
 		'::/128',         // Unspecified address
 	];
 	/** @var array<string, string[]> $resolve_ok */
@@ -394,6 +395,35 @@ final class FreshRSS_http_Util {
 				continue;
 			}
 
+			// NAT64 addresses use the RFC 6052 well-known prefix.
+			// Validate the embedded IPv4 address because the NAT64 IPv6 address itself
+			// is globally routable even when it maps to a private/reserved IPv4 address.
+			if (self::checkCIDR($ip, '64:ff9b::/96')) {
+				$packed = @inet_pton($ip);
+				if ($packed === false || strlen($packed) !== 16) {
+					continue;
+				}
+
+				$embedded_ipv4 = @inet_ntop(substr($packed, 12, 4));
+				if ($embedded_ipv4 === false) {
+					continue;
+				}
+
+				if (filter_var($embedded_ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
+					continue;
+				}
+
+				// Extra check because the above one might not be enough: https://github.com/php/php-src/issues/16944
+				foreach (self::PRIVATE_SUBNETS as $cidr) {
+					if (self::checkCIDR($embedded_ipv4, $cidr)) {
+						continue 2;
+					}
+				}
+
+				$ips_ok[] = $add_ip;
+				continue;
+			}
+
 			if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
 				continue;
 			}

+ 52 - 1
tests/app/Utils/httpUtilTest.php

@@ -6,13 +6,19 @@ use PHPUnit\Framework\Attributes\DataProvider;
 /**
  * Tests for FreshRSS_http_Util
  */
-class httpUtilTest extends \PHPUnit\Framework\TestCase {
+final class httpUtilTest extends \PHPUnit\Framework\TestCase {
 
 	#[DataProvider('provideUrlsIgnoringHttps')]
 	public function test_compareUrlIgnoringHttps(string $url1, string $url2, bool $expected): void {
 		self::assertEquals($expected, FreshRSS_http_Util::compareUrlIgnoringHttps($url1, $url2) === 0);
 	}
 
+	#[\Override]
+	protected function tearDown(): void {
+		$resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
+		$resolveOk->setValue(null, []);	// Restore the default empty cache
+	}
+
 	#[DataProvider('provideUrlsForRetryAfter')]
 	public function test_getRetryAfterFile(string $url1, string $url2, bool $sameFile): void {
 		$getRetryAfterFile = new ReflectionMethod(FreshRSS_http_Util::class, 'getRetryAfterFile');
@@ -90,4 +96,49 @@ class httpUtilTest extends \PHPUnit\Framework\TestCase {
 			['ftp://example.net/feed', 'https://example.net/feed', false],
 		];
 	}
+
+	public function test_getCurlResolveInfoAcceptsPublicNat64Address(): void {
+		FreshRSS_Context::initSystem();
+		$resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
+		$resolveOk->setValue(null, [
+			'example.test' => [
+				'192.0.66.96',
+				'64:ff9b::c000:4260',
+			],
+		]);
+
+		self::assertSame(
+			['example.test:443:192.0.66.96,[64:ff9b::c000:4260]'],
+			FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed')
+		);
+	}
+
+	public function test_getCurlResolveInfoRejectsPrivateNat64Address(): void {
+		FreshRSS_Context::initSystem();
+		$resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
+		$resolveOk->setValue(null, [
+			'example.test' => [
+				'64:ff9b::a9fe:a9fe',
+			],
+		]);
+
+		self::assertNull(
+			FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed')
+		);
+	}
+
+	public function test_getCurlResolveInfoRejectsLocalUseNat64Address(): void {
+		FreshRSS_Context::initSystem();
+		$resolveOk = new ReflectionProperty(FreshRSS_http_Util::class, 'resolve_ok');
+		$resolveOk->setValue(null, [
+			'example.test' => [
+				'64:ff9b:1::c000:4260',
+				'64:ff9b:1::a9fe:a9fe',
+			],
+		]);
+
+		self::assertNull(
+			FreshRSS_http_Util::getCurlResolveInfo('https://example.test/feed')
+		);
+	}
 }