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

nette / http / 27922157098

21 Jun 2026 11:58PM UTC coverage: 82.955% (-0.2%) from 83.128%
27922157098

push

github

dg
uses #Deprecated wip

1061 of 1279 relevant lines covered (82.96%)

0.83 hits per line

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

79.37
/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 readonly 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
         * @return string[]  A records first, then AAAA, in dotted-quad / RFC 5952 form
77
         */
78
        public function getResolvedIPs(string|UrlImmutable|null $url): array
1✔
79
        {
80
                $host = $this->parseAndValidate($url);
1✔
81
                if ($host === null) {
1✔
82
                        return [];
1✔
83
                }
84

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

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

104

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

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

123
                if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
1✔
124
                        $host = substr($host, 1, -1);
1✔
125
                }
126
                if (str_ends_with($host, '.')) {
1✔
127
                        $host = substr($host, 0, -1);
1✔
128
                }
129

130
                return match (true) {
131
                        $host === '' || str_ends_with($host, '.') => null,
1✔
132
                        !$this->allowUserinfo && (isset($parts['user']) || isset($parts['pass'])) => null,
1✔
133
                        !in_array($scheme, $this->schemes, true) => null,
1✔
134
                        $this->ports !== null && ($effectivePort === null || !in_array($effectivePort, $this->ports, true)) => null,
1✔
135
                        $this->hostAllowlist !== null && !self::matchesAnyPattern($host, $this->hostAllowlist) => null,
1✔
136
                        $this->hostBlocklist !== null && self::matchesAnyPattern($host, $this->hostBlocklist) => null,
1✔
137
                        default => $host,
1✔
138
                };
139
        }
140

141

142
        private function isIPAllowed(IPAddress $ip): bool
1✔
143
        {
144
                return match (true) {
145
                        $ip->isMulticast() => false,
1✔
146
                        $ip->isLoopback() => $this->allowLoopback,
1✔
147
                        $ip->isLinkLocal() => $this->allowLinkLocal,
1✔
148
                        $ip->isPrivate() => $this->allowPrivateIps,
1✔
149
                        $ip->isReserved() => $this->allowReserved,
1✔
150
                        default => true,
1✔
151
                };
152
        }
153

154

155
        /** @param  string[]  $patterns */
156
        private static function matchesAnyPattern(string $host, array $patterns): bool
1✔
157
        {
158
                $host = strtolower($host);
1✔
159
                foreach ($patterns as $pattern) {
1✔
160
                        $pattern = strtolower($pattern);
1✔
161
                        if (str_starts_with($pattern, '*.')) {
1✔
162
                                $suffix = substr($pattern, 1);
1✔
163
                                if (strlen($host) > strlen($suffix) && str_ends_with($host, $suffix)) {
1✔
164
                                        return true;
1✔
165
                                }
166
                        } elseif ($host === $pattern) {
1✔
167
                                return true;
1✔
168
                        }
169
                }
170
                return false;
1✔
171
        }
172

173

174
        /** @return list<string> */
175
        private static function resolveHost(string $host): array
176
        {
177
                $a = @dns_get_record($host, DNS_A) ?: [];
×
178
                $aaaa = @dns_get_record($host, DNS_AAAA) ?: [];
×
179
                return array_merge(
×
180
                        array_column($a, 'ip'),
×
181
                        array_column($aaaa, 'ipv6'),
×
182
                );
183
        }
184
}
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