diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/host.php b/snappymail/v/0.0.0/app/libraries/snappymail/host.php new file mode 100644 index 000000000..18e119593 --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/snappymail/host.php @@ -0,0 +1,312 @@ + self::UnwrapEmbeddedIPv4($ip) ?: $ip, $ips); + + // Numeric IPv4 literals in non-standard forms (http://2130706433/, + // http://017700000001/, http://0x7f000001/, http://127.1/, + // http://1.2.3.04/): the HTTP client parses these as IPs directly + // (verified: curl connects to 127.0.0.1 for the hex forms with no + // DNS involved) while dns_get_record() below would query them as + // hostnames, so normalize to dotted decimal before validation. + $ips = \array_map(fn($ip) => self::NormalizeNumericIPv4($ip) ?: $ip, $ips); + + // Check them all + if (\array_all($ips, fn($ip) => self::IsPublicIP($ip))) { + return true; + } + + return false; + } + + private static ?array $aLocalAddresses = null; + + private static function PrefixMatches(string $bin, string $net, int $bits) : bool + { + $bytes = \intdiv($bits, 8); + if (\substr($bin, 0, $bytes) !== \substr($net, 0, $bytes)) { + return false; + } + $rest = $bits % 8; + if (!$rest) { + return true; + } + $mask = (0xff << (8 - $rest)) & 0xff; + return (\ord($bin[$bytes]) & $mask) === (\ord($net[$bytes]) & $mask); + } + + /** + * Normalize a numeric IPv4 literal in non-standard form to dotted + * decimal, following inet_aton semantics: 1-4 dot-separated parts, + * each decimal, octal (leading zero) or hex (0x prefix); the last + * part takes all remaining bits (32/24/16/8). Returns null when the + * host is not such a literal. A part like "08" is not parsed as + * numeric by the resolver, so it is left for the hostname path below. + */ + private static function NormalizeNumericIPv4(string $host) : ?string + { + $aParts = \explode('.', $host); + $iCount = \count($aParts); + if ($iCount < 1 || $iCount > 4) { + return null; + } + $aNums = array(); + foreach ($aParts as $sPart) { + if ('' === $sPart + || !\preg_match('/^(0[xX][0-9a-fA-F]+|[0-9]+)$/', $sPart)) { + return null; + } + if (\preg_match('/^0[xX]/', $sPart)) { + if (10 < \strlen($sPart)) { + return null; // longer than 0x + 8 digits: not a 32-bit value + } + $aNums[] = \hexdec($sPart); + continue; + } + if (1 < \strlen($sPart) && '0' === $sPart[0] + && !\preg_match('/^0[0-7]+$/', $sPart)) { + return null; + } + if (12 < \strlen($sPart)) { + return null; // longer than 037777777777: not a 32-bit value + } + $aNums[] = 1 < \strlen($sPart) && '0' === $sPart[0] + ? \octdec($sPart) : (int) $sPart; + } + $iMax = array(4294967295, 16777215, 65535, 255); + for ($i = 0; $i < $iCount - 1; $i++) { + if ($aNums[$i] > 255) { + return null; + } + } + if ($aNums[$iCount - 1] > $iMax[$iCount - 1]) { + return null; + } + switch ($iCount) { + case 1: + $long = $aNums[0]; + break; + case 2: + $long = ($aNums[0] << 24) | $aNums[1]; + break; + case 3: + $long = ($aNums[0] << 24) | ($aNums[1] << 16) | $aNums[2]; + break; + default: + $long = ($aNums[0] << 24) | ($aNums[1] << 16) | ($aNums[2] << 8) | $aNums[3]; + break; + } + return \long2ip($long); + } + + /** + * Unwrap an IPv6 literal with an embedded IPv4 address to dotted + * decimal, or null when the host is not such a literal. Covers the + * IPv4-mapped range ::ffff:0:0/96 (dotted or hex tail), the NAT64 + * well-known prefix 64:ff9b::/96, and the deprecated + * IPv4-compatible form ::a.b.c.d (textual dotted-quad tail only, + * so ::1 stays loopback and is never mistaken for ::0.0.0.1). + * Needed because FILTER_FLAG_NO_RES_RANGE does not treat these + * ranges as reserved even though they reach IPv4 addresses in + * practice. + */ + private static function UnwrapEmbeddedIPv4(string $ip) : ?string + { + $sBin = \inet_pton($ip); + if (false === $sBin || 16 !== \strlen($sBin)) { + return null; // not an IPv6 literal + } + $sPrefix = \substr($sBin, 0, 12); + if ("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" === $sPrefix // ::ffff:0:0/96 + || "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === $sPrefix) { // 64:ff9b::/96 + return (string) \inet_ntop(\substr($sBin, 12, 4)); + } + // 6to4, 2002:V4ADDR::/48 (RFC 3056): the address sits in bytes 2 to 5, so + // 2002:7f00:1:: is 127.0.0.1. Deprecated by RFC 7526 and unreachable + // without a configured tunnel, but a host that has one would route it. + if ("\x20\x02" === \substr($sBin, 0, 2)) { + return (string) \inet_ntop(\substr($sBin, 2, 4)); + } + $sLower = \strtolower($ip); + if (\str_starts_with($sLower, '::') + && false !== \filter_var(\substr($sLower, 2), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { + return \substr($sLower, 2); // deprecated ::a.b.c.d form + } + return null; + } +} diff --git a/test/ssrf-ranges.php b/test/ssrf-ranges.php new file mode 100644 index 000000000..83c12f6b7 --- /dev/null +++ b/test/ssrf-ranges.php @@ -0,0 +1,40 @@ + $Request::URIHasPublicHost("http://{$host}/x.png"); + +foreach (['100.64.0.1', '100.100.100.200', '100.127.255.254', '192.0.0.1', '198.18.0.1', + '198.19.255.254', '224.0.0.1', '239.255.255.250', '[fec0::1]', '[ff02::1]', + '127.0.0.1', '10.0.0.1', '169.254.169.254', '[::1]', '[fc00::1]', '[::ffff:100.64.0.1]'] as $host) { + check(!$public($host), "{$host} treated as public"); +} +foreach (['100.63.255.255', '100.128.0.1', '198.17.255.255', '198.20.0.1', '8.8.8.8', + '[2001:4860:4860::8888]'] as $host) { + check($public($host), "{$host} wrongly blocked"); +} + +// The server's own addresses. With --network=none only loopback exists, so +// hand the cache an address to stand for this host's public IP. +$prop = new ReflectionProperty($Request, 'aLocalAddresses'); +$prop->setValue(null, [inet_pton('203.0.113.7'), inet_pton('2001:db8:1::53')]); +check(!$public('203.0.113.7'), "the server's own IPv4 address treated as public"); +check(!$public('[2001:db8:1::53]'), "the server's own IPv6 address treated as public"); +check($public('203.0.113.8'), 'a neighbouring address wrongly blocked'); + +check($public('snappymail.eu'), 'snapymail.eu wrongly blocked'); +check(!$public('localhost'), 'localhost should be blocked'); + +echo "ssrf-ranges: ok\n";