Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.19% |
33 / 37 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
| SsrfGuard | |
88.89% |
32 / 36 |
|
50.00% |
3 / 6 |
25.86 | |
0.00% |
0 / 1 |
| isUrlSafe | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| assertUrlSafe | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| extractValidHost | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| validateHostIps | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| resolveIps | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
4.12 | |||
| isSingleIpSafe | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
5.12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Security\Http; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use InvalidArgumentException; |
| 12 | |
| 13 | /** |
| 14 | * Enterprise SSRF (Server-Side Request Forgery) Guard. |
| 15 | * |
| 16 | * Validates outgoing HTTP/cURL target endpoints against loopback, RFC 1918 private |
| 17 | * subnets, cloud instance metadata services, and non-standard protocol handlers. |
| 18 | * |
| 19 | * @package App\Core\Security\Http |
| 20 | */ |
| 21 | final readonly class SsrfGuard |
| 22 | { |
| 23 | private const string METADATA_IP = '169.254.169.254'; // NOSONAR(php:S1313) |
| 24 | private const array ALLOWED_SCHEMES = ['http', 'https']; |
| 25 | private const array BLOCKED_HOSTNAMES = [ |
| 26 | 'localhost', |
| 27 | 'localhost.localdomain', |
| 28 | 'metadata.google.internal', |
| 29 | 'instance-data', |
| 30 | ]; |
| 31 | private const array BLOCKED_IPS = [ |
| 32 | self::METADATA_IP, |
| 33 | '169.254.170.2', // NOSONAR(php:S1313) |
| 34 | '127.0.0.1', // NOSONAR(php:S1313) |
| 35 | '0.0.0.0', // NOSONAR(php:S1313) |
| 36 | '::1', // NOSONAR(php:S1313) |
| 37 | ]; |
| 38 | |
| 39 | /** |
| 40 | * Checks if a target endpoint URL is safe from SSRF attacks. |
| 41 | * |
| 42 | * @param string $url Candidate endpoint URL. |
| 43 | * @param bool $allowPrivate Whether to permit RFC 1918 / loopback (for local testing). |
| 44 | * @return bool True if safe, false otherwise. |
| 45 | */ |
| 46 | public static function isUrlSafe(string $url, bool $allowPrivate = false): bool |
| 47 | { |
| 48 | $host = self::extractValidHost($url); |
| 49 | if ($host === null || $host === self::METADATA_IP) { |
| 50 | return false; |
| 51 | } |
| 52 | |
| 53 | if (in_array($host, self::BLOCKED_HOSTNAMES, true) || in_array($host, self::BLOCKED_IPS, true)) { |
| 54 | return $allowPrivate; |
| 55 | } |
| 56 | |
| 57 | return self::validateHostIps($host, $allowPrivate); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Asserts that a target URL is safe, throwing an exception if unsafe. |
| 62 | * |
| 63 | * @param string $url Candidate URL. |
| 64 | * @param bool $allowPrivate Whether to permit private addresses. |
| 65 | * @return string Validated safe URL. |
| 66 | * @throws InvalidArgumentException When URL violates SSRF safety rules. |
| 67 | */ |
| 68 | public static function assertUrlSafe(string $url, bool $allowPrivate = false): string |
| 69 | { |
| 70 | if (!self::isUrlSafe($url, $allowPrivate)) { |
| 71 | throw new InvalidArgumentException( |
| 72 | sprintf('SSRF Protection rejected destination URL "%s". Destination is restricted.', $url) |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | return $url; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Extracts and validates scheme and host from candidate endpoint URL. |
| 81 | */ |
| 82 | private static function extractValidHost(string $url): ?string |
| 83 | { |
| 84 | $trimmed = trim($url); |
| 85 | $parts = $trimmed !== '' ? parse_url($trimmed) : false; |
| 86 | if ($parts === false || !isset($parts['scheme'], $parts['host'])) { |
| 87 | return null; |
| 88 | } |
| 89 | |
| 90 | if (!in_array(strtolower($parts['scheme']), self::ALLOWED_SCHEMES, true)) { |
| 91 | return null; |
| 92 | } |
| 93 | |
| 94 | $host = strtolower(trim($parts['host'])); |
| 95 | return $host !== '' ? $host : null; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Validates resolved IP addresses for a given host string. |
| 100 | */ |
| 101 | private static function validateHostIps(string $host, bool $allowPrivate): bool |
| 102 | { |
| 103 | $resolvedIps = self::resolveIps($host); |
| 104 | if (empty($resolvedIps)) { |
| 105 | return false; |
| 106 | } |
| 107 | |
| 108 | foreach ($resolvedIps as $ip) { |
| 109 | if (!self::isSingleIpSafe($ip, $allowPrivate)) { |
| 110 | return false; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | return true; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Resolves host string to an array of IP addresses. |
| 119 | * |
| 120 | * @return list<string> |
| 121 | */ |
| 122 | private static function resolveIps(string $host): array |
| 123 | { |
| 124 | if (filter_var($host, FILTER_VALIDATE_IP) !== false) { |
| 125 | return [$host]; |
| 126 | } |
| 127 | |
| 128 | $ips = gethostbynamel($host); |
| 129 | return is_array($ips) ? array_values($ips) : []; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Checks safety of an individual IP address. |
| 134 | */ |
| 135 | private static function isSingleIpSafe(string $ip, bool $allowPrivate): bool |
| 136 | { |
| 137 | if (in_array($ip, self::BLOCKED_IPS, true) && (!$allowPrivate || $ip === self::METADATA_IP)) { |
| 138 | return false; |
| 139 | } |
| 140 | |
| 141 | if (!$allowPrivate) { |
| 142 | $isPublic = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); |
| 143 | return $isPublic !== false; |
| 144 | } |
| 145 | |
| 146 | return true; |
| 147 | } |
| 148 | } |