diff --git a/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php index f4d3ce92..725e31f4 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php @@ -20,6 +20,10 @@ final class CurlPlaywrightFetcher implements Fetcher public function site(string $url): FetchedSite { + if (! $this->isSafeUrl($url)) { + return new FetchedSite(url: $url, rawHtml: ''); + } + $raw = $this->curl($url); $visible = []; @@ -29,7 +33,7 @@ final class CurlPlaywrightFetcher implements Fetcher $visible = $rendered['visiblePhones'] ?? []; // номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка $contactsUrl = $this->guessContactsUrl($url, $raw); - if ($contactsUrl !== null) { + if ($contactsUrl !== null && $this->isSafeUrl($contactsUrl)) { $rc = $this->render($contactsUrl); if ($rc !== null) { foreach (($rc['visiblePhones'] ?? []) as $p) { @@ -54,10 +58,12 @@ final class CurlPlaywrightFetcher implements Fetcher $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, + CURLOPT_FOLLOWLOCATION => false, // защита от SSRF через редирект на внутренний адрес + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)', - CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, ]); $body = curl_exec($ch); curl_close($ch); @@ -65,6 +71,34 @@ final class CurlPlaywrightFetcher implements Fetcher return is_string($body) ? $body : ''; } + /** + * Пускаем только публичные http/https адреса; блокируем loopback, приватные + * и служебные диапазоны (защита от SSRF — заход на внутренние сервисы). + */ + private function isSafeUrl(string $url): bool + { + $parts = parse_url($url); + if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = $parts['host']; + $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []); + if ($ips === []) { + return false; + } + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; // приватный/служебный/loopback — не ходим + } + } + + return true; + } + /** @return array{html:string,visiblePhones:list}|null */ private function render(string $url): ?array { diff --git a/app/scripts/render-page.cjs b/app/scripts/render-page.cjs index 81bc9f8f..d4c3252a 100644 --- a/app/scripts/render-page.cjs +++ b/app/scripts/render-page.cjs @@ -4,6 +4,18 @@ const { chromium } = require('playwright'); (async () => { const url = process.argv[2]; + // Защита от SSRF/локального доступа: только http/https. + let parsed; + try { + parsed = new URL(url); + } catch (e) { + console.error('bad url'); + process.exit(2); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + console.error('bad scheme'); + process.exit(2); + } const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage();