3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 03:06:23 +00:00

Tighter restrictions checking for internal IPs

This commit is contained in:
snipe
2026-08-10 13:45:05 +01:00
parent 638da28a00
commit cb24e3e334
5 changed files with 227 additions and 27 deletions

View File

@ -0,0 +1,111 @@
<?php
namespace App\Helpers;
/**
* Shared SSRF-guard check for whether a resolved IP address refers to a
* publicly-routable target. Callers that accept a URL or hostname from
* an admin (webhook endpoints, LDAP hosts, etc.) validate the resolved
* address through this helper so the "block internal targets" behavior
* lives in one place.
*
* PHP's built-in `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`
* combo covers most of what we need, but has two known gaps that this
* class handles explicitly:
*
* 1. The unspecified address (`::` and `0.0.0.0`) is not classified as
* reserved by filter_var across all PHP versions.
*
* 2. IPv6 transition prefixes (NAT64, 6to4, Teredo) embed an IPv4
* target inside a globally-routable IPv6 wrapper. filter_var only
* inspects the wrapper prefix, so an attacker who supplies an IPv6
* literal like `64:ff9b::169.254.169.254` slips past the guard on
* any host that has NAT64 (or 6to4 or Teredo) tunneling enabled.
* See GHSA-5j6m-rr83-rpj7.
*
* The transition-address extraction is transport-agnostic on purpose.
* We don't check whether the current host actually has the tunnel up.
* Defense in depth means blocking these prefixes regardless of the
* environment they'd be reachable in.
*/
class PublicIpCheck
{
/**
* True when the address resolves to a publicly-routable target. Returns
* false for loopback, RFC-1918 private, link-local, unique-local,
* multicast, broadcast, unspecified, and IPv6 transition prefixes whose
* embedded IPv4 would itself be non-public.
*/
public static function isPublic(string $ip): bool
{
// Unwrap IPv4-mapped IPv6 (::ffff:x.x.x.x) so the IPv4
// NO_PRIV_RANGE / NO_RES_RANGE checks apply to the payload.
if (stripos($ip, '::ffff:') === 0) {
$ipv4 = substr($ip, 7);
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = $ipv4;
}
}
// Explicit unspecified-address block. filter_var's classification
// of `::` and `0.0.0.0` isn't consistent across PHP versions.
if ($ip === '::' || $ip === '0.0.0.0') {
return false;
}
// IPv6 transition prefixes: extract the embedded IPv4 and recurse.
// A non-public embedded IPv4 fails the check regardless of the
// wrapper being technically routable IPv6 space.
$embedded = self::extractTransitionIpv4($ip);
if ($embedded !== null) {
return self::isPublic($embedded);
}
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
/**
* If the address is an IPv6 transition prefix (NAT64, 6to4, or Teredo)
* that carries an IPv4 payload, return that payload as a dotted-quad
* string. Otherwise null.
*/
private static function extractTransitionIpv4(string $ip): ?string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
return null;
}
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$words = array_values(unpack('n8', $packed));
// NAT64 well-known prefix (RFC 6052): 64:ff9b::/96.
// Last 32 bits hold the embedded IPv4.
if ($words[0] === 0x0064 && $words[1] === 0xFF9B
&& $words[2] === 0 && $words[3] === 0
&& $words[4] === 0 && $words[5] === 0) {
return long2ip(($words[6] << 16) | $words[7]);
}
// 6to4 (RFC 3056): 2002::/16.
// Bits 16-47 (the next two 16-bit words) hold the embedded IPv4.
if ($words[0] === 0x2002) {
return long2ip(($words[1] << 16) | $words[2]);
}
// Teredo (RFC 4380): 2001:0000::/32.
// Client IPv4 lives in bits 96-127, XOR'd with 0xFFFFFFFF.
if ($words[0] === 0x2001 && $words[1] === 0x0000) {
return long2ip((($words[6] ^ 0xFFFF) << 16) | ($words[7] ^ 0xFFFF));
}
return null;
}
}

View File

@ -1348,14 +1348,13 @@ class LdapSettings extends Component
}
if (! config('app.test_allow_private_ips')) {
// FILTER_FLAG_NO_PRIV_RANGE blocks 10/8, 172.16/12, 192.168/16, fc00::/7.
// FILTER_FLAG_NO_RES_RANGE blocks loopback (127/8, ::1), link-local
// (169.254/16 - cloud metadata!), multicast, broadcast, and other
// IETF-reserved ranges.
// While this makes sense in some cases, we control that via the
// TEST_ALLOW_PRIVATE_IPS env var, since some folks will legitimately need
// their install of Snipe-IT to talk to internal networks
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
// Block loopback, RFC-1918, link-local (169.254/16 -- cloud
// metadata!), multicast, broadcast, and IPv6 transition
// prefixes that would embed a non-public IPv4 payload. See
// App\Helpers\PublicIpCheck for the exact ranges covered.
// The check is gated on TEST_ALLOW_PRIVATE_IPS because some
// installs legitimately need to talk to internal LDAP servers.
if (! \App\Helpers\PublicIpCheck::isPublic($ip)) {
return trans('admin/settings/general.ldap_wizard.test.private_ip_blocked', ['host' => $host, 'ip' => $ip]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Rules;
use App\Helpers\PublicIpCheck;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
@ -66,7 +67,7 @@ class ExternalUrl implements ValidationRule
}
foreach ($ips as $ip) {
if (! $this->isPublicIp($ip)) {
if (! PublicIpCheck::isPublic($ip)) {
$fail(trans('validation.external_url'));
return;
@ -74,24 +75,6 @@ class ExternalUrl implements ValidationRule
}
}
private function isPublicIp(string $ip): bool
{
// Unwrap IPv4-mapped IPv6 so ::ffff:127.0.0.1 doesn't sneak past
// the IPv4 private/reserved checks.
if (stripos($ip, '::ffff:') === 0) {
$ipv4 = substr($ip, 7);
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = $ipv4;
}
}
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
private function resolveHost(string $host): array
{
$ips = [];

View File

@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Helpers;
use App\Helpers\PublicIpCheck;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class PublicIpCheckTest extends TestCase
{
public static function publicProvider(): array
{
return [
'public ipv4 google' => ['8.8.8.8'],
'public ipv4 cloudflare' => ['1.1.1.1'],
'public ipv6 cloudflare' => ['2606:4700:4700::1111'],
'public ipv6 google' => ['2001:4860:4860::8888'],
'v4-mapped public v4' => ['::ffff:8.8.8.8'],
'nat64 public target' => ['64:ff9b::8.8.8.8'],
'nat64 public target hex' => ['64:ff9b::808:808'],
'6to4 public target' => ['2002:0808:0808::'],
];
}
public static function nonPublicProvider(): array
{
return [
// Loopback
'ipv4 loopback' => ['127.0.0.1'],
'ipv4 loopback high' => ['127.255.255.254'],
'ipv6 loopback' => ['::1'],
// RFC-1918 private
'rfc1918 10' => ['10.0.0.1'],
'rfc1918 172' => ['172.16.5.5'],
'rfc1918 192' => ['192.168.1.1'],
// Link-local (AWS/GCP/Azure IMDS lives here)
'link-local v4' => ['169.254.169.254'],
'link-local v6' => ['fe80::1'],
// IPv6 unique local
'unique local fc' => ['fc00::1'],
'unique local fd' => ['fd12:3456::1'],
// Unspecified
'unspecified v4' => ['0.0.0.0'],
'unspecified v6' => ['::'],
// IPv4-mapped IPv6 wrapping a private target
'v4-mapped loopback' => ['::ffff:127.0.0.1'],
'v4-mapped rfc1918' => ['::ffff:10.0.0.1'],
'v4-mapped imds' => ['::ffff:169.254.169.254'],
// NAT64 wrapping non-public targets (GHSA-5j6m-rr83-rpj7)
'nat64 loopback dotted' => ['64:ff9b::127.0.0.1'],
'nat64 loopback hex' => ['64:ff9b::7f00:1'],
'nat64 imds dotted' => ['64:ff9b::169.254.169.254'],
'nat64 imds hex' => ['64:ff9b::a9fe:a9fe'],
'nat64 rfc1918' => ['64:ff9b::10.0.0.1'],
// 6to4 wrapping non-public targets
'6to4 loopback' => ['2002:7f00:1::'],
'6to4 imds' => ['2002:a9fe:a9fe::'],
'6to4 rfc1918' => ['2002:0a00:1::'],
// Teredo wrapping non-public target
// client IPv4 in bits 96-127 XOR'd with 0xffffffff
// 10.0.0.1 -> XOR'd = f5ff:fffe
'teredo rfc1918' => ['2001:0000:4136:e378:8000:63bf:f5ff:fffe'],
// Garbage
'not an ip' => ['not-an-ip'],
'empty string' => [''],
];
}
#[DataProvider('publicProvider')]
public function test_recognizes_publicly_routable_addresses(string $ip): void
{
$this->assertTrue(
PublicIpCheck::isPublic($ip),
$ip.' should be considered publicly routable',
);
}
#[DataProvider('nonPublicProvider')]
public function test_rejects_non_public_addresses(string $ip): void
{
$this->assertFalse(
PublicIpCheck::isPublic($ip),
$ip.' should NOT be considered publicly routable',
);
}
}

View File

@ -55,6 +55,18 @@ class ExternalUrlTest extends TestCase
'v4-mapped loopback' => ['http://[::ffff:127.0.0.1]/'],
'v4-mapped rfc1918' => ['http://[::ffff:10.0.0.1]/'],
// IPv6 transition-prefix sneak-in (GHSA-5j6m-rr83-rpj7).
// NAT64, 6to4, and Teredo all embed an IPv4 target inside
// a globally-routable IPv6 wrapper. filter_var doesn't
// inspect the payload, so PublicIpCheck extracts and
// re-validates it.
'nat64 imds' => ['http://[64:ff9b::169.254.169.254]/latest/meta-data/'],
'nat64 imds hex' => ['http://[64:ff9b::a9fe:a9fe]/'],
'nat64 loopback' => ['http://[64:ff9b::127.0.0.1]/'],
'6to4 loopback' => ['http://[2002:7f00:1::]/'],
'6to4 imds' => ['http://[2002:a9fe:a9fe::]/'],
'teredo rfc1918' => ['http://[2001:0000:4136:e378:8000:63bf:f5ff:fffe]/'],
// Malformed.
'no scheme' => ['example.com'],
'no host' => ['http:///'],