diff --git a/app/Console/Commands/CheckLinksCommand.php b/app/Console/Commands/CheckLinksCommand.php index b3fffa94..314d4e04 100644 --- a/app/Console/Commands/CheckLinksCommand.php +++ b/app/Console/Commands/CheckLinksCommand.php @@ -81,6 +81,11 @@ protected function checkLink(Link $link): void { $this->output->write('Checking link ' . $link->url . ' '); + if (config('html-meta.block_private_ips', true) && $this->pointsToPrivateIp($link->url)) { + $this->line('› Skipped (private IP).'); + return; + } + try { $request = setupHttpRequest(timeout: 20); $response = $request->head($link->url); @@ -100,6 +105,61 @@ protected function checkLink(Link $link): void } } + protected function pointsToPrivateIp(string $url): bool + { + $host = trim(parse_url($url, PHP_URL_HOST) ?? '', '[]'); + + if ($host === '') { + return false; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return !$this->isPublicIp($host); + } + + foreach ($this->resolveHostIps($host) as $ip) { + if (!$this->isPublicIp($ip)) { + return true; + } + } + + return false; + } + + protected function isPublicIp(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + } + + /** + * @return array + */ + protected function resolveHostIps(string $host): array + { + $ips = []; + $records = @dns_get_record($host, DNS_A + DNS_AAAA); + + if (is_array($records)) { + foreach ($records as $record) { + if (isset($record['ip'])) { + $ips[] = $record['ip']; + } + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + + if (empty($ips)) { + $ipv4Addresses = @gethostbynamel($host); + if (is_array($ipv4Addresses)) { + $ips = $ipv4Addresses; + } + } + + return array_values(array_unique($ips)); + } + protected function processMovedLink(Link $link): void { $link->status = Link::STATUS_MOVED; diff --git a/tests/Commands/CheckLinksCommandTest.php b/tests/Commands/CheckLinksCommandTest.php index 2c852166..58e6fb4f 100644 --- a/tests/Commands/CheckLinksCommandTest.php +++ b/tests/Commands/CheckLinksCommandTest.php @@ -89,6 +89,44 @@ public function test_check_with_moved_or_broken_links(): void ); } + public function test_check_skips_private_ip_links(): void + { + Http::fake(); + Notification::fake(); + + $user = User::factory()->create(); + Link::factory()->for($user)->create(['url' => 'http://192.168.1.1/']); + Link::factory()->for($user)->create(['url' => 'http://127.0.0.1/']); + Link::factory()->for($user)->create(['url' => 'http://169.254.169.254/latest/meta-data/']); + Link::factory()->for($user)->create(['url' => 'http://[::1]/']); + + config(['html-meta.block_private_ips' => true]); + + $this->artisan('links:check --noWait'); + + Http::assertNothingSent(); + Notification::assertNothingSent(); + + // Status and last_checked_at should remain untouched + $this->assertDatabaseMissing('links', ['status' => Link::STATUS_BROKEN]); + $this->assertDatabaseMissing('links', ['last_checked_at' => now()]); + } + + public function test_check_allows_private_ip_links_when_config_disabled(): void + { + Http::fake(['*' => Http::response()]); + Notification::fake(); + + $user = User::factory()->create(); + Link::factory()->for($user)->create(['url' => 'http://192.168.1.1/']); + + config(['html-meta.block_private_ips' => false]); + + $this->artisan('links:check --noWait'); + + Http::assertSentCount(1); + } + public function test_check_without_links(): void { Notification::fake();