Files
portal/app/app/Support/WebhookUrlGuard.php
T
Дмитрий b81a372e8f feat(security): webhook DNS-rebind пиннинг + аддитивный HMAC supplier-webhook — edge/P2 go-live
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>
2026-06-17 20:21:11 +03:00

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;
}
}