b81a372e8f
WebhookUrlGuard::safeDeliveryIp один резолв + CURLOPT_RESOLVE пиннинг в test(); supplier-webhook принимает HMAC X-Webhook-Signature как альтернативу URL-секрету + secretless-маршрут. Аддитивно, backward-compat. 6 новых тестов GREEN; 5 падений webhook-сюиты pre-existing (Phase-3 B-regex + CsvWebhookRaceTest), подтверждено baseline без моих файлов. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
5.5 KiB
PHP
135 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* SSRF-гард для исходящих webhook-URL.
|
|
*
|
|
* Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
|
|
* указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
|
|
* `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
|
|
* ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
|
|
*
|
|
* Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
|
|
* IP. Неразрешимый хост (NXDOMAIN) — не SSRF-вектор, пропускаем (реальный запрос
|
|
* упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
|
|
*/
|
|
final class WebhookUrlGuard
|
|
{
|
|
/**
|
|
* @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
|
|
*/
|
|
public static function blockReason(string $url): ?string
|
|
{
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
if (! is_string($host) || $host === '') {
|
|
return 'Некорректный URL webhook.';
|
|
}
|
|
$host = trim($host, '[]'); // снять скобки IPv6-литерала
|
|
|
|
foreach (self::resolve($host) as $ip) {
|
|
if (! self::isPublicIp($ip)) {
|
|
return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Один резолв хоста для доставки: причина блокировки И безопасный IP для
|
|
* пиннинга соединения. Закрывает DNS-rebind TOCTOU — блок-решение и адрес
|
|
* подключения берутся из ОДНОГО резолва, а не из двух независимых (как было
|
|
* бы при blockReason + повторный резолв в HTTP-клиенте).
|
|
*
|
|
* @return array{ip: string|null, blockReason: string|null}
|
|
*/
|
|
public static function safeDeliveryIp(string $url): array
|
|
{
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
if (! is_string($host) || $host === '') {
|
|
return ['ip' => null, 'blockReason' => 'Некорректный URL webhook.'];
|
|
}
|
|
$host = trim($host, '[]');
|
|
|
|
$ips = self::resolve($host);
|
|
foreach ($ips as $ip) {
|
|
if (! self::isPublicIp($ip)) {
|
|
return ['ip' => null, 'blockReason' => 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.'];
|
|
}
|
|
}
|
|
|
|
// Все записи публичны (или хост не резолвится → ip=null, пиннинг не
|
|
// применяется, запрос упадёт сам — как и в blockReason).
|
|
return ['ip' => $ips[0] ?? null, 'blockReason' => null];
|
|
}
|
|
|
|
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
|
|
private static function resolve(string $host): array
|
|
{
|
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
|
return [$host]; // IP-литерал — без DNS
|
|
}
|
|
|
|
$ips = [];
|
|
$v4 = gethostbynamel($host);
|
|
if (is_array($v4)) {
|
|
$ips = array_merge($ips, $v4);
|
|
}
|
|
$aaaa = @dns_get_record($host, DNS_AAAA);
|
|
if (is_array($aaaa)) {
|
|
foreach ($aaaa as $rec) {
|
|
if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
|
|
$ips[] = $rec['ipv6'];
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($ips));
|
|
}
|
|
|
|
private static function isPublicIp(string $ip): bool
|
|
{
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
|
return filter_var(
|
|
$ip,
|
|
FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
|
) !== false;
|
|
}
|
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
|
$lower = strtolower($ip);
|
|
// loopback / unspecified
|
|
if ($lower === '::1' || $lower === '::') {
|
|
return false;
|
|
}
|
|
// link-local fe80::/10
|
|
if (preg_match('/^fe[89ab]/', $lower) === 1) {
|
|
return false;
|
|
}
|
|
// unique-local fc00::/7
|
|
if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
|
|
return false;
|
|
}
|
|
// IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
|
|
if (str_contains($lower, '::ffff:')) {
|
|
$v4 = substr($lower, (int) strrpos($lower, ':') + 1);
|
|
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
|
return self::isPublicIp($v4);
|
|
}
|
|
}
|
|
|
|
return filter_var(
|
|
$ip,
|
|
FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
|
) !== false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|