Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.14% covered (success)
91.14%
72 / 79
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SsrfGuard
91.14% covered (success)
91.14%
72 / 79
77.78% covered (warning)
77.78%
7 / 9
40.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertSafe
89.29% covered (warning)
89.29%
50 / 56
0.00% covered (danger)
0.00%
0 / 1
23.65
 allowedSchemes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowedHosts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowsLoopback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowsPrivateIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCgnat
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isMulticastV4
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isMulticastV6
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ResourceLoader;
6
7use Phpdftk\ResourceLoader\Exception\SsrfBlockedException;
8
9/**
10 * Synchronous URL safety check. Rejects URLs that would otherwise
11 * let a renderer reach the host's internal network: loopback,
12 * RFC 1918 private ranges, link-local, CGNAT, multicast, reserved,
13 * and any non-`http(s)` scheme.
14 *
15 * Hostnames are NOT DNS-resolved at validation time — this guard is
16 * pure synchronous logic suitable for use anywhere a URL crosses a
17 * trust boundary. The HTTP fetcher (Phase 4F.1) re-runs the IP
18 * check after DNS resolution so DNS-rebinding attacks are caught
19 * too.
20 *
21 * Override the defaults to allow specific ranges or hosts:
22 *
23 *   new SsrfGuard(
24 *       allowedSchemes: ['http', 'https'],
25 *       allowedHosts:   ['fonts.googleapis.com', 'fonts.gstatic.com'],
26 *       allowLoopback:  false,
27 *       allowPrivateIp: false,
28 *   );
29 */
30final class SsrfGuard
31{
32    /**
33     * @param list<string> $allowedSchemes URI schemes the guard
34     *                                     permits. Default
35     *                                     `['http', 'https']`.
36     * @param list<string> $allowedHosts   Hostname allowlist. When
37     *                                     non-empty, the URL's host
38     *                                     must exactly match one of
39     *                                     these entries. Empty (the
40     *                                     default) means
41     *                                     "allow any host that
42     *                                     passes the IP / scheme
43     *                                     checks".
44     * @param bool $allowLoopback          Permit 127/8 + ::1.
45     *                                     Useful for local-dev test
46     *                                     servers.
47     * @param bool $allowPrivateIp         Permit RFC 1918, link-
48     *                                     local, CGNAT, etc.
49     *                                     Required if you serve
50     *                                     internal hosts that must
51     *                                     resolve to private
52     *                                     addresses.
53     */
54    public function __construct(
55        private readonly array $allowedSchemes = ['http', 'https'],
56        private readonly array $allowedHosts = [],
57        private readonly bool $allowLoopback = false,
58        private readonly bool $allowPrivateIp = false,
59    ) {}
60
61    /**
62     * Validate a URL. Throws {@see SsrfBlockedException} with a
63     * message documenting the exact policy violation if the URL is
64     * unsafe.
65     */
66    public function assertSafe(string $url): void
67    {
68        $parsed = parse_url($url);
69        if ($parsed === false || $parsed === null) {
70            throw new SsrfBlockedException(sprintf('URL is malformed: %s', $url));
71        }
72
73        $scheme = strtolower($parsed['scheme'] ?? '');
74        if ($scheme === '') {
75            throw new SsrfBlockedException(sprintf('URL is missing a scheme: %s', $url));
76        }
77        if (!in_array($scheme, $this->allowedSchemes, true)) {
78            throw new SsrfBlockedException(sprintf(
79                'Scheme "%s" is not in the allowlist [%s]: %s',
80                $scheme,
81                implode(', ', $this->allowedSchemes),
82                $url,
83            ));
84        }
85
86        $host = $parsed['host'] ?? '';
87        if ($host === '') {
88            throw new SsrfBlockedException(sprintf('URL is missing a host: %s', $url));
89        }
90
91        // Strip the brackets PHP leaves around IPv6 hosts so the
92        // filter_var IP check sees the bare address.
93        $bareHost = $host;
94        if (str_starts_with($bareHost, '[') && str_ends_with($bareHost, ']')) {
95            $bareHost = substr($bareHost, 1, -1);
96        }
97
98        if ($this->allowedHosts !== [] && !in_array($host, $this->allowedHosts, true) && !in_array($bareHost, $this->allowedHosts, true)) {
99            throw new SsrfBlockedException(sprintf(
100                'Host "%s" is not in the allowlist [%s]: %s',
101                $host,
102                implode(', ', $this->allowedHosts),
103                $url,
104            ));
105        }
106
107        // If the host is an IP literal, run it through filter_var's
108        // private + reserved range checks. filter_var's
109        // FILTER_FLAG_NO_RES_RANGE covers 0/8, 127/8, 169.254/16,
110        // 224/4 (multicast), 240/4 (reserved), ::, ::1, fe80::/10,
111        // and IPv4-mapped IPv6 in those ranges. FILTER_FLAG_NO_PRIV_RANGE
112        // covers 10/8, 172.16/12, 192.168/16, and fc00::/7.
113        if (filter_var($bareHost, FILTER_VALIDATE_IP) !== false) {
114            $flags = 0;
115            if (!$this->allowPrivateIp) {
116                $flags |= FILTER_FLAG_NO_PRIV_RANGE;
117            }
118            if (!$this->allowLoopback && !$this->allowPrivateIp) {
119                $flags |= FILTER_FLAG_NO_RES_RANGE;
120            }
121            if ($flags !== 0 && filter_var($bareHost, FILTER_VALIDATE_IP, $flags) === false) {
122                throw new SsrfBlockedException(sprintf(
123                    'Host "%s" is a loopback / private / reserved IP: %s',
124                    $bareHost,
125                    $url,
126                ));
127            }
128            // Reject CGNAT 100.64.0.0/10 — not covered by filter_var
129            // flags but RFC 6598 makes it carrier-grade NAT space
130            // that shouldn't be a public destination.
131            if (!$this->allowPrivateIp && self::isCgnat($bareHost)) {
132                throw new SsrfBlockedException(sprintf(
133                    'Host "%s" is in the CGNAT range 100.64.0.0/10: %s',
134                    $bareHost,
135                    $url,
136                ));
137            }
138            // Reject IPv4 multicast 224.0.0.0/4 — filter_var's
139            // NO_RES_RANGE doesn't include it; we don't want a
140            // renderer joining a multicast group.
141            if (!$this->allowPrivateIp && self::isMulticastV4($bareHost)) {
142                throw new SsrfBlockedException(sprintf(
143                    'Host "%s" is in the IPv4 multicast range 224.0.0.0/4: %s',
144                    $bareHost,
145                    $url,
146                ));
147            }
148            // Reject IPv6 multicast ff00::/8 — same reason.
149            if (!$this->allowPrivateIp && self::isMulticastV6($bareHost)) {
150                throw new SsrfBlockedException(sprintf(
151                    'Host "%s" is in the IPv6 multicast range ff00::/8: %s',
152                    $bareHost,
153                    $url,
154                ));
155            }
156        }
157    }
158
159    /**
160     * @return list<string>
161     */
162    public function allowedSchemes(): array
163    {
164        return $this->allowedSchemes;
165    }
166
167    /**
168     * @return list<string>
169     */
170    public function allowedHosts(): array
171    {
172        return $this->allowedHosts;
173    }
174
175    public function allowsLoopback(): bool
176    {
177        return $this->allowLoopback;
178    }
179
180    public function allowsPrivateIp(): bool
181    {
182        return $this->allowPrivateIp;
183    }
184
185    /**
186     * Check whether a dotted-quad IPv4 address falls in the RFC 6598
187     * CGNAT range `100.64.0.0/10` (`100.64.0.0` through
188     * `100.127.255.255`). filter_var's NO_PRIV_RANGE doesn't catch
189     * this, so we handle it explicitly.
190     */
191    private static function isCgnat(string $ip): bool
192    {
193        $parts = explode('.', $ip);
194        if (count($parts) !== 4) {
195            return false;
196        }
197        if ((int) $parts[0] !== 100) {
198            return false;
199        }
200        $second = (int) $parts[1];
201        return $second >= 64 && $second <= 127;
202    }
203
204    /**
205     * Check whether a dotted-quad IPv4 address falls in the
206     * multicast range `224.0.0.0/4` (RFC 5771).
207     */
208    private static function isMulticastV4(string $ip): bool
209    {
210        $parts = explode('.', $ip);
211        if (count($parts) !== 4) {
212            return false;
213        }
214        $first = (int) $parts[0];
215        return $first >= 224 && $first <= 239;
216    }
217
218    /**
219     * Check whether an IPv6 address falls in the multicast range
220     * `ff00::/8` (RFC 4291).
221     */
222    private static function isMulticastV6(string $ip): bool
223    {
224        // Only meaningful for IPv6 — reject non-v6 silently.
225        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
226            return false;
227        }
228        $packed = @inet_pton($ip);
229        if ($packed === false || strlen($packed) !== 16) {
230            return false;
231        }
232        return ord($packed[0]) === 0xff;
233    }
234}