Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
54 / 56
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SsrfProtectionClient
96.36% covered (success)
96.36%
53 / 55
66.67% covered (warning)
66.67%
4 / 6
27
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 request
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
7
 assertUrlAllowed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assertAndResolveUrl
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
16
 isIpBlocked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Modules\Integrations\Infrastructure\Security;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Domain\Exception\IntegrationNetworkException;
12use InvalidArgumentException;
13
14/**
15 * OWASP ASVS v4.0.3 & NIST SP 800-53 compliant HTTP client with SSRF protection.
16 * Prevents requests to internal networks, private IP ranges, loopbacks, and cloud metadata endpoints.
17 */
18final class SsrfProtectionClient implements SsrfProtectionClientInterface
19{
20    /**
21     * Executes GET request with SSRF validation.
22     *
23     * @param string                $url     Target URL.
24     * @param array<string, string> $headers Request headers.
25     * @param int                   $timeout Max timeout in seconds.
26     * @return array{status: int, body: string}
27     */
28    public function get(string $url, array $headers = [], int $timeout = 3): array
29    {
30        return $this->request($url, 'GET', $headers, null, $timeout);
31    }
32
33    /**
34     * Executes POST request with SSRF validation.
35     *
36     * @param string                $url     Target URL.
37     * @param array<string, string> $headers Request headers.
38     * @param string|null           $body    Request payload.
39     * @param int                   $timeout Max timeout in seconds.
40     * @return array{status: int, body: string}
41     */
42    public function post(string $url, array $headers = [], ?string $body = null, int $timeout = 3): array
43    {
44        return $this->request($url, 'POST', $headers, $body, $timeout);
45    }
46
47    /**
48     * Executes an outbound HTTP request with SSRF validation and strict timeout limits.
49     *
50     * @param string                $url     Target API endpoint.
51     * @param string                $method  HTTP method ('GET', 'POST', etc.).
52     * @param array<string, string> $headers Request headers.
53     * @param string|null           $body    Optional JSON or form payload.
54     * @param int                   $timeout Max request timeout in seconds.
55     * @return array{status: int, body: string} HTTP response status code and body.
56     * @throws InvalidArgumentException If SSRF violation occurs.
57     * @throws IntegrationNetworkException If network or cURL error occurs.
58     */
59    public function request(
60        string $url,
61        string $method = 'GET',
62        array $headers = [],
63        ?string $body = null,
64        int $timeout = 3
65    ): array {
66        $target = $this->assertAndResolveUrl($url);
67
68        $ch = curl_init();
69        if ($ch === false) {
70            throw new IntegrationNetworkException('Failed to initialize cURL handle');
71        }
72
73        $formattedHeaders = [];
74        foreach ($headers as $key => $val) {
75            $formattedHeaders[] = "{$key}{$val}";
76        }
77
78        $curlOptions = [
79            CURLOPT_URL            => $url,
80            CURLOPT_RETURNTRANSFER => true,
81            CURLOPT_CUSTOMREQUEST  => strtoupper($method),
82            CURLOPT_HTTPHEADER     => $formattedHeaders,
83            CURLOPT_TIMEOUT        => max(1, min($timeout, 10)),
84            CURLOPT_CONNECTTIMEOUT => 2,
85            CURLOPT_FOLLOWLOCATION => false, // OWASP ASVS: Disable follow location to prevent redirect SSRF
86            CURLOPT_SSL_VERIFYPEER => true,
87            CURLOPT_SSL_VERIFYHOST => 2,
88        ];
89
90        // Pin resolved safe IP to prevent DNS Rebinding attacks (OWASP ASVS V12.6)
91        if (!filter_var($target['host'], FILTER_VALIDATE_IP)) {
92            $curlOptions[CURLOPT_RESOLVE] = ["{$target['host']}:{$target['port']}:{$target['ip']}"];
93        }
94
95        curl_setopt_array($ch, $curlOptions);
96
97        if ($body !== null && strtoupper($method) !== 'GET') {
98            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
99        }
100
101        /** @var string|false $result */
102        $result = curl_exec($ch);
103        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
104        $curlError = curl_error($ch);
105        curl_close($ch);
106
107        if ($result === false) {
108            throw new IntegrationNetworkException("Integration API connection error: {$curlError}");
109        }
110
111        return [
112            'status' => $httpCode,
113            'body'   => $result,
114        ];
115    }
116
117    /**
118     * Validates whether a target URL is safe and does not resolve to a private/internal network.
119     *
120     * @param string $url URL to validate.
121     * @throws InvalidArgumentException If URL is forbidden.
122     */
123    public function assertUrlAllowed(string $url): void
124    {
125        $this->assertAndResolveUrl($url);
126    }
127
128    /**
129     * Validates target URL and returns resolved connection parameters with safe IP address.
130     *
131     * @param string $url URL to validate.
132     * @return array{host: string, port: int, ip: string} Connection parameters.
133     * @throws InvalidArgumentException If URL is forbidden.
134     */
135    public function assertAndResolveUrl(string $url): array
136    {
137        $parts = parse_url($url);
138        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
139            throw new InvalidArgumentException('Invalid URL format supplied for integration');
140        }
141
142        $scheme = strtolower((string) $parts['scheme']);
143        if ($scheme !== 'https' && $scheme !== 'http') {
144            throw new InvalidArgumentException('Forbidden URL scheme: only HTTP and HTTPS are permitted');
145        }
146
147        $host = (string) $parts['host'];
148        $defaultPort = $scheme === 'https' ? 443 : 80;
149        $port = !empty($parts['port']) ? (int) $parts['port'] : $defaultPort;
150
151        // Block IPv6 localhost / link-local addresses
152        if ($host === 'localhost' || $host === '::1' || str_starts_with($host, 'fe80:')) {
153            throw new InvalidArgumentException('Forbidden internal destination: localhost or private network');
154        }
155
156        // Resolve DNS and check resulting IP addresses
157        $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : gethostbynamel($host);
158        if ($ips === false || empty($ips)) {
159            throw new InvalidArgumentException("Could not resolve host name: {$host}");
160        }
161
162        foreach ($ips as $ip) {
163            if ($this->isIpBlocked($ip)) {
164                throw new InvalidArgumentException("Blocked private or internal IP address: {$ip}");
165            }
166        }
167
168        return ['host' => $host, 'port' => $port, 'ip' => $ips[0]];
169    }
170
171    /**
172     * Checks whether an IP address belongs to any blocked private or link-local range.
173     *
174     * @param string $ip IP address.
175     * @return bool True if IP is in a blocked range.
176     */
177    public function isIpBlocked(string $ip): bool
178    {
179        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
180    }
181}