diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b08f176 Binary files /dev/null and b/.DS_Store differ diff --git a/app/Models/Site.php b/app/Models/Site.php index b4fdf5b..8a4caed 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Network\NetworkHelper; use App\RemoteSite\Connection; use GuzzleHttp\Client; use Illuminate\Database\Eloquent\Model; @@ -38,7 +39,18 @@ protected function casts(): array public function getUrlAttribute(string $value): string { - return rtrim($value, "/"); + $url = rtrim($value, "/"); + + $networkHelper = App::make(NetworkHelper::class); + + // Revalidate current DNS resolution on every usage + if (!$networkHelper->isValidRemoteHost( + (string) parse_url($value, PHP_URL_HOST) + )) { + throw new \RuntimeException("Invalid URL provided"); + } + + return $url; } public function getConnectionAttribute(): Connection diff --git a/app/Network/DNSLookup.php b/app/Network/NetworkHelper.php similarity index 51% rename from app/Network/DNSLookup.php rename to app/Network/NetworkHelper.php index fe8777e..ca1cae8 100644 --- a/app/Network/DNSLookup.php +++ b/app/Network/NetworkHelper.php @@ -2,7 +2,7 @@ namespace App\Network; -class DNSLookup +class NetworkHelper { public function getIPs(string $hostname): array { @@ -26,4 +26,35 @@ public function getIPs(string $hostname): array return $ips; } + + public function isValidRemoteHost(string $hostname): bool + { + $ips = $this->getIPs($hostname); + + if (!count($ips)) { + return false; + } + + // Check each resolved IP + foreach ($ips as $ip) { + if (!$this->isValidRemoteIp($ip)) { + return false; + } + } + + return true; + } + + public function isValidRemoteIp(string $ip): bool + { + if (!filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + )) { + return false; + } + + return true; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7027757..724ed61 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ namespace App\Providers; -use App\Network\DNSLookup; +use App\Network\NetworkHelper; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -34,7 +34,7 @@ public function boot(): void $siteIpLimits = []; if ($siteHost !== 'default') { - $siteIps = (new DNSLookup())->getIPs($siteHost); + $siteIps = (new NetworkHelper())->getIPs($siteHost); foreach ($siteIps as $siteIp) { $siteIpLimits[] = Limit::perMinute(5)->by("siteip-" . $siteIp); diff --git a/app/Rules/RemoteURL.php b/app/Rules/RemoteURL.php index f879985..4db8a0f 100644 --- a/app/Rules/RemoteURL.php +++ b/app/Rules/RemoteURL.php @@ -2,7 +2,7 @@ namespace App\Rules; -use App\Network\DNSLookup; +use App\Network\NetworkHelper; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\App; @@ -23,23 +23,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $host = (string) parse_url($value, PHP_URL_HOST); - $ips = App::make(DNSLookup::class)->getIPs($host); + /** @var NetworkHelper $networkHelper */ + $networkHelper = App::make(NetworkHelper::class); - // Could not resolve given address - if (count($ips) === 0) { - $fail("Invalid URL: unresolvable site URL."); - } - - // Check each resolved IP - foreach ($ips as $ip) { - if (!filter_var( - $ip, - FILTER_VALIDATE_IP, - FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE - ) - ) { - $fail("Invalid URL: local address are disallowed as site URL."); - } + // Check IPs + if (!$networkHelper->isValidRemoteHost($host)) { + $fail("Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs."); } } } diff --git a/tests/Unit/Network/DNSLookupTest.php b/tests/Unit/Network/DNSLookupTest.php deleted file mode 100644 index 88fafe2..0000000 --- a/tests/Unit/Network/DNSLookupTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertSame(['127.0.0.1'], $object->getIPs('127.0.0.1')); - } - - public function testEmptyArrayIsReturnedForInvalidHost() - { - $object = new DNSLookup(); - $this->assertSame([], $object->getIPs('invalid.host.with.bogus.tld')); - } - - public function testIpsAreReturned() - { - $object = new DNSLookup(); - $this->assertGreaterThan(5, $object->getIPs('joomla.org')); - } -} diff --git a/tests/Unit/Network/NetworkHelperTest.php b/tests/Unit/Network/NetworkHelperTest.php new file mode 100644 index 0000000..79ff790 --- /dev/null +++ b/tests/Unit/Network/NetworkHelperTest.php @@ -0,0 +1,59 @@ +assertSame(['127.0.0.1'], $object->getIPs('127.0.0.1')); + } + + public function testEmptyArrayIsReturnedForInvalidHost() + { + $object = new NetworkHelper(); + $this->assertSame([], $object->getIPs('invalid.host.with.bogus.tld')); + } + + public function testIpsAreReturned() + { + $object = new NetworkHelper(); + $this->assertGreaterThan(5, $object->getIPs('joomla.org')); + } + + #[DataProvider('hostnameDataProvider')] + public function testRemoteIpsAreValidate($ip, $result) + { + $object = new NetworkHelper(); + $this->assertEquals($result, $object->isValidRemoteHost($ip)); + } + + #[DataProvider('ipDataProvider')] + public function testLocalIpsAreForbidden($ip, $result) + { + $object = new NetworkHelper(); + $this->assertEquals($result, $object->isValidRemoteIp($ip)); + } + + public static function hostnameDataProvider(): array + { + return [ + ['localhost', false], + ['joomla.org', true] + ]; + } + + public static function ipDataProvider(): array + { + return [ + ['127.0.0.1', false], + ['10.0.0.1', false], + ['8.8.8.8', true] + ]; + } +} diff --git a/tests/Unit/Rules/RemoteURLTest.php b/tests/Unit/Rules/RemoteURLTest.php index 860ecf6..d6439b0 100644 --- a/tests/Unit/Rules/RemoteURLTest.php +++ b/tests/Unit/Rules/RemoteURLTest.php @@ -28,11 +28,11 @@ public function testRuleHandlesIpsAndHosts($host, $expectedResult, $expectedMess public static function urlDataProvider(): array { return [ - ['https://127.0.0.1', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://localhost', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://10.0.0.1', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://joomla.org', true, ''], - ['https://invalid.host.tld', false,'Invalid URL: unresolvable site URL.'], + ['https://127.0.0.1', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://localhost', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://10.0.0.1', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://invalid.host.tld', false,'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://joomla.org', true, ''] ]; } }