• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / http / 27449837026

12 Jun 2026 11:51PM UTC coverage: 83.128% (+0.1%) from 83.025%
27449837026

push

github

dg
deprecated wip

1079 of 1298 relevant lines covered (83.13%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.67
/src/Http/UrlValidator.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Http;
9

10
use function array_column, array_merge, dns_get_record, in_array, parse_url, str_ends_with, str_starts_with, strlen, strtolower, substr;
11
use const DNS_A, DNS_AAAA;
12

13

14
/**
15
 * Validates URLs against a configurable policy: scheme, port, host allow/blocklist,
16
 * userinfo, and resolved IP ranges. Used to guard server-side URL fetches against
17
 * SSRF — set the policy to match your threat model.
18
 */
19
final class UrlValidator
20
{
21
        /** Implicit ports for known schemes (used when URL has no explicit port). */
22
        private const SchemePorts = ['http' => 80, 'https' => 443];
23

24

25
        /**
26
         * Host patterns: exact ('example.com') or wildcard subdomain ('*.example.com'
27
         * matches any depth but NOT apex; for apex list both forms). Multicast addresses
28
         * are always rejected — never opt-in.
29
         *
30
         * @param  string[]       $schemes         allowed schemes; empty array rejects all
31
         * @param  int[]|null     $ports           allowed ports, null = any; implicit port from scheme (https→443, http→80) is honored
32
         * @param  string[]|null  $hostAllowlist   if set, host must match one pattern; null = no allowlist; [] = reject all
33
         * @param  string[]|null  $hostBlocklist   if set, host must not match any pattern
34
         */
35
        public function __construct(
1✔
36
                private array $schemes = ['https'],
37
                private ?array $ports = [443],
38
                private bool $allowPrivateIps = false,
39
                private bool $allowLoopback = false,
40
                private bool $allowLinkLocal = false,
41
                private bool $allowReserved = false,
42
                private bool $allowUserinfo = false,
43
                private ?array $hostAllowlist = null,
44
                private ?array $hostBlocklist = null,
45
        ) {
46
        }
1✔
47

48

49
        /**
50
         * Returns true if URL passes the entire policy: parseable, allowed scheme/port,
51
         * userinfo policy, host allow/blocklist, and (for hostname hosts) every DNS-resolved
52
         * A/AAAA address passes the IP-range policy. DNS lookup is skipped when host is an
53
         * IP literal. Null URL returns false.
54
         */
55
        public function allows(string|UrlImmutable|null $url): bool
1✔
56
        {
57
                return $this->getResolvedIPs($url) !== [];
1✔
58
        }
59

60

61
        /**
62
         * Same as allows() without DNS resolution and IP-range checks. Useful as a
63
         * fast pre-filter, or when DNS validation is delegated to the fetch layer
64
         * (e.g. CURLOPT_RESOLVE).
65
         */
66
        public function allowsWithoutDns(string|UrlImmutable|null $url): bool
1✔
67
        {
68
                return $this->parseAndValidate($url) !== null;
1✔
69
        }
70

71

72
        /**
73
         * Returns DNS-resolved IPs that passed the full policy, or [] on any failure.
74
         * Pass returned IPs to your HTTP client's connection pinning (CURLOPT_RESOLVE)
75
         * to bind the actual fetch to validated addresses, defeating DNS rebinding race.
76
         *
77
         * @return string[]  A records first, then AAAA, in dotted-quad / RFC 5952 form
78
         */
79
        public function getResolvedIPs(string|UrlImmutable|null $url): array
1✔
80
        {
81
                $host = $this->parseAndValidate($url);
1✔
82
                if ($host === null) {
1✔
83
                        return [];
1✔
84
                }
85

86
                // IP literal: validate directly, no DNS
87
                if (IPAddress::isValid($host)) {
1✔
88
                        return $this->isIPAllowed(new IPAddress($host)) ? [$host] : [];
1✔
89
                }
90

91
                // Hostname: resolve and validate every A/AAAA record
92
                $ips = self::resolveHost($host);
×
93
                if (!$ips) {
×
94
                        return [];
×
95
                }
96
                foreach ($ips as $ip) {
×
97
                        $addr = IPAddress::tryFrom($ip);
×
98
                        if ($addr === null || !$this->isIPAllowed($addr)) {
×
99
                                return [];
×
100
                        }
101
                }
102
                return $ips;
×
103
        }
104

105

106
        /**
107
         * Returns the host (with brackets stripped from IPv6 literals) on policy pass,
108
         * null otherwise. Performs no DNS resolution.
109
         */
110
        private function parseAndValidate(string|UrlImmutable|null $url): ?string
1✔
111
        {
112
                if ($url === null) {
1✔
113
                        return null;
1✔
114
                }
115

116
                $parts = parse_url((string) $url);
1✔
117
                if ($parts === false || empty($parts['host']) || empty($parts['scheme'])) {
1✔
118
                        return null;
1✔
119
                }
120
                $scheme = strtolower($parts['scheme']);
1✔
121
                $host = $parts['host'];
1✔
122

123
                // Strip brackets from IPv6 host literal (parse_url leaves them in)
124
                if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
1✔
125
                        $host = substr($host, 1, -1);
1✔
126
                }
127

128
                // A fully-qualified domain may end with the DNS root dot ('example.com.' is the same
129
                // host as 'example.com'); strip it so it cannot be used to dodge the host patterns
130
                if (str_ends_with($host, '.')) {
1✔
131
                        $host = substr($host, 0, -1);
1✔
132
                }
133
                if ($host === '' || str_ends_with($host, '.')) { // empty host or remaining empty label
1✔
134
                        return null;
1✔
135
                }
136

137
                if (!$this->allowUserinfo && (isset($parts['user']) || isset($parts['pass']))) {
1✔
138
                        return null;
1✔
139
                }
140

141
                if (!in_array($scheme, $this->schemes, true)) {
1✔
142
                        return null;
1✔
143
                }
144

145
                if ($this->ports !== null) {
1✔
146
                        $effectivePort = $parts['port'] ?? self::SchemePorts[$scheme] ?? null;
1✔
147
                        if ($effectivePort === null || !in_array($effectivePort, $this->ports, true)) {
1✔
148
                                return null;
1✔
149
                        }
150
                }
151

152
                if ($this->hostAllowlist !== null && !self::matchesAnyPattern($host, $this->hostAllowlist)) {
1✔
153
                        return null;
1✔
154
                }
155
                if ($this->hostBlocklist !== null && self::matchesAnyPattern($host, $this->hostBlocklist)) {
1✔
156
                        return null;
1✔
157
                }
158

159
                return $host;
1✔
160
        }
161

162

163
        private function isIPAllowed(IPAddress $ip): bool
1✔
164
        {
165
                if ($ip->isMulticast()) {
1✔
166
                        return false;
1✔
167
                }
168
                if ($ip->isLoopback()) {
1✔
169
                        return $this->allowLoopback;
1✔
170
                }
171
                if ($ip->isLinkLocal()) {
1✔
172
                        return $this->allowLinkLocal;
1✔
173
                }
174
                if ($ip->isPrivate()) {
1✔
175
                        return $this->allowPrivateIps;
1✔
176
                }
177
                if ($ip->isReserved()) {
1✔
178
                        return $this->allowReserved;
1✔
179
                }
180
                return true;
1✔
181
        }
182

183

184
        /**
185
         * @param  string[]  $patterns
186
         */
187
        private static function matchesAnyPattern(string $host, array $patterns): bool
1✔
188
        {
189
                $host = strtolower($host);
1✔
190
                foreach ($patterns as $pattern) {
1✔
191
                        $pattern = strtolower($pattern);
1✔
192
                        if (str_starts_with($pattern, '*.')) {
1✔
193
                                $suffix = substr($pattern, 1); // '.example.com'
1✔
194
                                if (strlen($host) > strlen($suffix) && str_ends_with($host, $suffix)) {
1✔
195
                                        return true;
1✔
196
                                }
197
                        } elseif ($host === $pattern) {
1✔
198
                                return true;
1✔
199
                        }
200
                }
201
                return false;
1✔
202
        }
203

204

205
        /**
206
         * @return string[]
207
         */
208
        private static function resolveHost(string $host): array
209
        {
210
                $a = @dns_get_record($host, DNS_A) ?: [];
×
211
                $aaaa = @dns_get_record($host, DNS_AAAA) ?: [];
×
212
                return array_merge(
×
213
                        array_column($a, 'ip'),
×
214
                        array_column($aaaa, 'ipv6'),
×
215
                );
216
        }
217
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc