Skip to content

Commit 822707f

Browse files
committed
added IPAddress and UrlValidator
IPAddress: immutable IPv4/IPv6 value object with predicates for address class (isPublic/isPrivate/isLoopback/isLinkLocal/isMulticast/isReserved), isInRange() for CIDR matching, and IPv4-mapped IPv6 normalization. UrlValidator: configurable URL validator for SSRF guard. Validates scheme, port, host allow/blocklist, userinfo, and (optionally with DNS) resolved IP ranges. Returns resolved IPs for connection pinning via CURLOPT_RESOLVE.
1 parent 6148386 commit 822707f

6 files changed

Lines changed: 989 additions & 0 deletions

File tree

src/Http/IPAddress.php

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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 Nette;
11+
use function chr, ctype_digit, explode, filter_var, inet_ntop, inet_pton, intdiv, str_contains, str_repeat, strlen, strncmp, substr;
12+
use const FILTER_FLAG_IPV4, FILTER_FLAG_IPV6, FILTER_VALIDATE_IP;
13+
14+
15+
/**
16+
* Immutable IPv4/IPv6 address with predicates for range membership and address class.
17+
*/
18+
final class IPAddress implements \Stringable
19+
{
20+
private const PrivateRanges = [
21+
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7',
22+
];
23+
24+
private const LoopbackRanges = ['127.0.0.0/8', '::1/128'];
25+
26+
private const LinkLocalRanges = ['169.254.0.0/16', 'fe80::/10'];
27+
28+
private const MulticastRanges = ['224.0.0.0/4', 'ff00::/8'];
29+
30+
private const ReservedRanges = [
31+
'0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15',
32+
'198.51.100.0/24', '203.0.113.0/24', '240.0.0.0/4', '255.255.255.255/32',
33+
'::/128', '64:ff9b::/96', '100::/64', '2001::/23', '2001:db8::/32',
34+
];
35+
36+
private string $binary;
37+
38+
39+
/**
40+
* @throws Nette\InvalidArgumentException when address is not a valid IPv4 or IPv6 address
41+
*/
42+
public function __construct(
43+
public readonly string $address,
44+
) {
45+
$bin = @inet_pton($address);
46+
if ($bin === false) {
47+
throw new Nette\InvalidArgumentException("Invalid IP address: $address");
48+
}
49+
$this->binary = $bin;
50+
}
51+
52+
53+
/**
54+
* Returns true for any syntactically valid IPv4 or IPv6 address,
55+
* including IPv4-mapped IPv6 (::ffff:1.2.3.4).
56+
*/
57+
public static function isValid(string $address): bool
58+
{
59+
return @inet_pton($address) !== false;
60+
}
61+
62+
63+
/**
64+
* Returns an instance for valid input, null otherwise.
65+
*/
66+
public static function tryFrom(string $address): ?self
67+
{
68+
return self::isValid($address) ? new self($address) : null;
69+
}
70+
71+
72+
/**
73+
* Returns true for IPv4 dotted-quad form. IPv4-mapped IPv6 returns false; see isIPv4Mapped().
74+
*/
75+
public function isIPv4(): bool
76+
{
77+
return (bool) filter_var($this->address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
78+
}
79+
80+
81+
/**
82+
* Returns true for IPv6 form, including IPv4-mapped (::ffff:1.2.3.4).
83+
*/
84+
public function isIPv6(): bool
85+
{
86+
return (bool) filter_var($this->address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
87+
}
88+
89+
90+
/**
91+
* Returns true for IPv4-mapped IPv6 (::ffff:a.b.c.d). Range predicates below
92+
* normalize these — ::ffff:127.0.0.1 evaluates as loopback.
93+
*/
94+
public function isIPv4Mapped(): bool
95+
{
96+
return strlen($this->binary) === 16
97+
&& substr($this->binary, 0, 10) === str_repeat("\0", 10)
98+
&& substr($this->binary, 10, 2) === "\xff\xff";
99+
}
100+
101+
102+
/**
103+
* Converts IPv4-mapped IPv6 to IPv4 dotted-quad form. Returns $this for non-mapped.
104+
*/
105+
public function toIPv4(): self
106+
{
107+
if (!$this->isIPv4Mapped()) {
108+
return $this;
109+
}
110+
$ipv4 = inet_ntop(substr($this->binary, 12));
111+
return $ipv4 === false ? $this : new self($ipv4);
112+
}
113+
114+
115+
/**
116+
* Tests whether this IP falls within the given CIDR block.
117+
*
118+
* Accepts '192.168.1.0/24' (network with prefix) or '192.168.1.1' (exact match,
119+
* implicit /32 for IPv4 or /128 for IPv6). Returns false for malformed input
120+
* or different IP family. IPv4-mapped IPv6 is normalized to IPv4 when the
121+
* range is in IPv4 form.
122+
*/
123+
public function isInRange(string $cidr): bool
124+
{
125+
if (str_contains($cidr, '/')) {
126+
[$network, $prefixStr] = explode('/', $cidr, 2);
127+
if (!ctype_digit($prefixStr)) {
128+
return false;
129+
}
130+
$prefix = (int) $prefixStr;
131+
} else {
132+
$network = $cidr;
133+
$prefix = null;
134+
}
135+
136+
$networkBin = @inet_pton($network);
137+
if ($networkBin === false) {
138+
return false;
139+
}
140+
141+
// IPv4-mapped IPv6 self matches IPv4 ranges (RFC 4291 § 2.5.5.2)
142+
$selfBin = strlen($networkBin) === 4 && $this->isIPv4Mapped()
143+
? substr($this->binary, 12)
144+
: $this->binary;
145+
if (strlen($networkBin) !== strlen($selfBin)) {
146+
return false;
147+
}
148+
149+
$maxBits = strlen($networkBin) * 8;
150+
$prefix ??= $maxBits;
151+
if ($prefix < 0 || $prefix > $maxBits) {
152+
return false;
153+
}
154+
155+
$fullBytes = intdiv($prefix, 8);
156+
if (strncmp($selfBin, $networkBin, $fullBytes) !== 0) {
157+
return false;
158+
}
159+
$remainBits = $prefix % 8;
160+
if ($remainBits === 0) {
161+
return true;
162+
}
163+
$mask = chr((0xFF << (8 - $remainBits)) & 0xFF);
164+
return ($selfBin[$fullBytes] & $mask) === ($networkBin[$fullBytes] & $mask);
165+
}
166+
167+
168+
/**
169+
* Returns true if address is publicly routable (not in any private, loopback,
170+
* link-local, multicast or reserved range).
171+
*/
172+
public function isPublic(): bool
173+
{
174+
return !$this->isPrivate()
175+
&& !$this->isLoopback()
176+
&& !$this->isLinkLocal()
177+
&& !$this->isMulticast()
178+
&& !$this->isReserved();
179+
}
180+
181+
182+
/**
183+
* Tests RFC 1918 / RFC 4193 private ranges (10/8, 172.16/12, 192.168/16, fc00::/7).
184+
*/
185+
public function isPrivate(): bool
186+
{
187+
return $this->matchesAny(self::PrivateRanges);
188+
}
189+
190+
191+
/**
192+
* Tests loopback ranges (127.0.0.0/8, ::1/128).
193+
*/
194+
public function isLoopback(): bool
195+
{
196+
return $this->matchesAny(self::LoopbackRanges);
197+
}
198+
199+
200+
/**
201+
* Tests link-local ranges (169.254.0.0/16 incl. cloud metadata 169.254.169.254, fe80::/10).
202+
*/
203+
public function isLinkLocal(): bool
204+
{
205+
return $this->matchesAny(self::LinkLocalRanges);
206+
}
207+
208+
209+
/**
210+
* Tests multicast ranges (224.0.0.0/4, ff00::/8).
211+
*/
212+
public function isMulticast(): bool
213+
{
214+
return $this->matchesAny(self::MulticastRanges);
215+
}
216+
217+
218+
/**
219+
* Tests IANA-reserved ranges not covered by other predicates: documentation prefixes
220+
* (192.0.2.0/24, 2001:db8::/32), CGNAT (100.64.0.0/10), benchmarking, future-use,
221+
* unspecified (0.0.0.0/8, ::/128) and similar.
222+
*/
223+
public function isReserved(): bool
224+
{
225+
return $this->matchesAny(self::ReservedRanges);
226+
}
227+
228+
229+
public function __toString(): string
230+
{
231+
return $this->address;
232+
}
233+
234+
235+
/**
236+
* @param string[] $ranges
237+
*/
238+
private function matchesAny(array $ranges): bool
239+
{
240+
foreach ($ranges as $cidr) {
241+
if ($this->isInRange($cidr)) {
242+
return true;
243+
}
244+
}
245+
return false;
246+
}
247+
}

0 commit comments

Comments
 (0)