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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user