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:
111
app/Helpers/PublicIpCheck.php
Normal file
111
app/Helpers/PublicIpCheck.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
95
tests/Unit/Helpers/PublicIpCheckTest.php
Normal file
95
tests/Unit/Helpers/PublicIpCheckTest.php
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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:///'],
|
||||
|
||||
Reference in New Issue
Block a user