From 94cb8cc3fa7212fb66db59101a89f2bc084e2805 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 27 Jan 2026 13:10:11 +0000 Subject: [PATCH] feat: add webhook security validation rules - SafeWebhookUrl: SSRF protection for webhook URLs (blocks private IPs, localhost, reserved ranges) - SafeJsonPayload: validates JSON payload structure and size Co-Authored-By: Claude Opus 4.5 --- src/Core/Rules/SafeJsonPayload.php | 153 +++++++++++++++ src/Core/Rules/SafeWebhookUrl.php | 296 +++++++++++++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/Core/Rules/SafeJsonPayload.php create mode 100644 src/Core/Rules/SafeWebhookUrl.php diff --git a/src/Core/Rules/SafeJsonPayload.php b/src/Core/Rules/SafeJsonPayload.php new file mode 100644 index 0000000..3b7fd89 --- /dev/null +++ b/src/Core/Rules/SafeJsonPayload.php @@ -0,0 +1,153 @@ + $this->maxSizeBytes) { + $fail("The :attribute exceeds the maximum allowed size of {$this->maxSizeBytes} bytes."); + + return; + } + + // Check structure + $keyCount = 0; + $depthError = false; + $stringError = false; + + $this->traverseArray($value, 1, $keyCount, $depthError, $stringError); + + if ($depthError) { + $fail("The :attribute exceeds the maximum nesting depth of {$this->maxDepth} levels."); + + return; + } + + if ($keyCount > $this->maxKeys) { + $fail("The :attribute exceeds the maximum of {$this->maxKeys} keys."); + + return; + } + + if ($stringError) { + $fail("The :attribute contains string values exceeding {$this->maxStringLength} characters."); + + return; + } + } + + /** + * Recursively traverse array to check depth, key count, and string lengths. + */ + protected function traverseArray(array $array, int $currentDepth, int &$keyCount, bool &$depthError, bool &$stringError): void + { + if ($currentDepth > $this->maxDepth) { + $depthError = true; + + return; + } + + foreach ($array as $key => $value) { + $keyCount++; + + if ($keyCount > $this->maxKeys) { + return; + } + + if (is_string($value) && strlen($value) > $this->maxStringLength) { + $stringError = true; + + return; + } + + if (is_array($value)) { + $this->traverseArray($value, $currentDepth + 1, $keyCount, $depthError, $stringError); + + if ($depthError || $stringError || $keyCount > $this->maxKeys) { + return; + } + } + } + } + + /** + * Create with default limits (10KB, 3 depth, 50 keys, 1000 char strings). + */ + public static function default(): self + { + return new self; + } + + /** + * Create with small payload limits (2KB, 2 depth, 20 keys, 500 char strings). + */ + public static function small(): self + { + return new self(2048, 2, 20, 500); + } + + /** + * Create with large payload limits (100KB, 5 depth, 200 keys, 5000 char strings). + */ + public static function large(): self + { + return new self(102400, 5, 200, 5000); + } + + /** + * Create for metadata/tags (5KB, 2 depth, 30 keys, 256 char strings). + */ + public static function metadata(): self + { + return new self(5120, 2, 30, 256); + } +} diff --git a/src/Core/Rules/SafeWebhookUrl.php b/src/Core/Rules/SafeWebhookUrl.php new file mode 100644 index 0000000..6a7da94 --- /dev/null +++ b/src/Core/Rules/SafeWebhookUrl.php @@ -0,0 +1,296 @@ + [ + 'discord.com', + 'discordapp.com', + ], + 'slack' => [ + 'hooks.slack.com', + ], + 'telegram' => [ + 'api.telegram.org', + ], + ]; + + /** + * Create a new rule instance. + * + * @param string|null $service Restrict to specific service domains (discord, slack, telegram) + */ + public function __construct( + protected ?string $service = null + ) {} + + /** + * Run the validation rule. + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (empty($value)) { + return; + } + + // Basic URL validation + if (! filter_var($value, FILTER_VALIDATE_URL)) { + $fail('The :attribute must be a valid URL.'); + + return; + } + + $parsed = parse_url($value); + $host = $parsed['host'] ?? ''; + $scheme = $parsed['scheme'] ?? ''; + + // Must be HTTPS for webhooks (security best practice) + if ($scheme !== 'https') { + $fail('The :attribute must use HTTPS.'); + + return; + } + + if (empty($host)) { + $fail('The :attribute must contain a valid hostname.'); + + return; + } + + // If restricted to specific service, validate domain + if ($this->service && isset(self::ALLOWED_DOMAINS[$this->service])) { + $allowedDomains = self::ALLOWED_DOMAINS[$this->service]; + $hostLower = strtolower($host); + + $matched = false; + foreach ($allowedDomains as $domain) { + if ($hostLower === $domain || str_ends_with($hostLower, '.'.$domain)) { + $matched = true; + break; + } + } + + if (! $matched) { + $serviceName = ucfirst($this->service); + $fail("The :attribute must be a valid {$serviceName} webhook URL."); + + return; + } + + // Known service domains are trusted, skip SSRF checks + return; + } + + // For custom webhooks, perform SSRF validation + if ($this->isLocalHostname($host)) { + $fail('The :attribute cannot point to localhost or local domains.'); + + return; + } + + // Check if it's an IP address + $normalizedIp = $this->normalizeIpAddress($host); + if ($normalizedIp !== null) { + if ($this->isPrivateOrLocalhost($normalizedIp)) { + $fail('The :attribute cannot point to localhost or private networks.'); + + return; + } + } + + // Resolve hostname and check all IPs + if ($normalizedIp === null) { + $resolvedIps = $this->resolveHostname($host); + + foreach ($resolvedIps as $ip) { + if ($this->isPrivateOrLocalhost($ip)) { + $fail('The :attribute resolves to a private or local address.'); + + return; + } + } + } + } + + /** + * Check if a hostname is a local/private domain. + */ + protected function isLocalHostname(string $host): bool + { + $host = strtolower(trim($host)); + + if ($host === 'localhost') { + return true; + } + + $localSuffixes = ['.local', '.localhost', '.internal', '.localdomain', '.home.arpa']; + + foreach ($localSuffixes as $suffix) { + if (str_ends_with($host, $suffix)) { + return true; + } + } + + return false; + } + + /** + * Normalize an IP address to canonical form. + */ + protected function normalizeIpAddress(string $host): ?string + { + $host = trim($host); + + // Handle bracketed IPv6 + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + } + + if (filter_var($host, FILTER_VALIDATE_IP)) { + $packed = @inet_pton($host); + if ($packed !== false) { + return inet_ntop($packed); + } + + return $host; + } + + // Handle decimal IP (e.g., 2130706433 for 127.0.0.1) + if (preg_match('/^\d+$/', $host)) { + $decimal = filter_var($host, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 0, 'max_range' => 4294967295], + ]); + if ($decimal !== false) { + return long2ip($decimal); + } + } + + return null; + } + + /** + * Resolve hostname to IP addresses. + */ + protected function resolveHostname(string $host): array + { + $ips = []; + + $ipv4Records = @dns_get_record($host, DNS_A); + if (is_array($ipv4Records)) { + foreach ($ipv4Records as $record) { + if (isset($record['ip'])) { + $ips[] = $record['ip']; + } + } + } + + $ipv6Records = @dns_get_record($host, DNS_AAAA); + if (is_array($ipv6Records)) { + foreach ($ipv6Records as $record) { + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + + // Fallback + if (empty($ips)) { + $fallback = @gethostbynamel($host); + if (is_array($fallback)) { + $ips = $fallback; + } + } + + return $ips; + } + + /** + * Check if an IP address is localhost or private. + */ + protected function isPrivateOrLocalhost(string $ip): bool + { + // IPv6 checks + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $packed = @inet_pton($ip); + if ($packed === false) { + return true; + } + + $normalized = inet_ntop($packed); + + if ($normalized === '::1') { + return true; + } + + // IPv4-mapped IPv6 + if (str_starts_with($normalized, '::ffff:')) { + $ipv4 = substr($normalized, 7); + if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->isPrivateIpv4($ipv4); + } + } + + return ! filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + } + + // IPv4 checks + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->isPrivateIpv4($ip); + } + + return true; + } + + /** + * Check if an IPv4 address is private or localhost. + */ + protected function isPrivateIpv4(string $ip): bool + { + $long = ip2long($ip); + if ($long === false) { + return true; + } + + // 127.0.0.0/8 + if (($long >> 24) === 127) { + return true; + } + + // 0.0.0.0/8 + if (($long >> 24) === 0) { + return true; + } + + return ! filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + } +}