Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.19% covered (warning)
89.19%
33 / 37
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SsrfGuard
88.89% covered (warning)
88.89%
32 / 36
50.00% covered (danger)
50.00%
3 / 6
25.86
0.00% covered (danger)
0.00%
0 / 1
 isUrlSafe
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 assertUrlSafe
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 extractValidHost
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 validateHostIps
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 resolveIps
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 isSingleIpSafe
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Http;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use 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 */
21final 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}