fix(автоподбор): укрепление fetcher — TLS-проверка, SSRF-страж, без редиректов

- CURLOPT_SSL_VERIFYPEER/VERIFYHOST включены
- isSafeUrl: только http/https, блок loopback/приватных/служебных IP
- FOLLOWLOCATION выключен, протоколы ограничены HTTP/HTTPS
- render-page.cjs валидирует схему URL перед навигацией

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 10:45:14 +03:00
parent aedefa3a94
commit 6f4e6de9a3
2 changed files with 49 additions and 3 deletions
@@ -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<string>}|null */
private function render(string $url): ?array
{
+12
View File
@@ -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();