Browse Source

Work on PHP 8.0 and 8.1: list non-public ranges explicitly

Guard relied on FILTER_FLAG_GLOBAL_RANGE, which only exists from PHP
8.2. It now checks addresses against its own list taken from the IANA
IPv4 and IPv6 special-purpose registries (plus multicast), so every PHP
version refuses the same addresses. The list also covers ranges the PHP
flag let through, such as 2001:db8::/32 and 3fff::/20.

Declare php >= 7.1 (the nullable parameter type needs it), so older PHP
versions keep resolving to 0.1.x. Tests run on PHPUnit 9.6 as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
Aaron Parecki 2 days ago
parent
commit
98b1aa07c9
3 changed files with 30 additions and 6 deletions
  1. +2
    -1
      composer.json
  2. +21
    -4
      src/p3k/HTTP/Guard.php
  3. +7
    -1
      tests/GuardTest.php

+ 2
- 1
composer.json View File

@ -11,6 +11,7 @@
} }
], ],
"require": { "require": {
"php": ">=7.1",
"indieweb/link-rel-parser": "0.1.*", "indieweb/link-rel-parser": "0.1.*",
"mf2/mf2": ">=0.3.2" "mf2/mf2": ">=0.3.2"
}, },
@ -25,7 +26,7 @@
} }
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10 || ^11"
"phpunit/phpunit": "^9.6 || ^10 || ^11"
}, },
"scripts": { "scripts": {
"test": "phpunit" "test": "phpunit"

+ 21
- 4
src/p3k/HTTP/Guard.php View File

@ -14,6 +14,18 @@ class Guard {
// read as an IP address. Refuse them rather than guess which one. // read as an IP address. Refuse them rather than guess which one.
const NUMERIC_HOST = '/^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+))*\.?$/i'; const NUMERIC_HOST = '/^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+))*\.?$/i';
// Addresses that are not on the public internet: the IANA IPv4 and IPv6
// special-purpose registries, plus multicast. Listed here rather than
// left to FILTER_FLAG_GLOBAL_RANGE, which needs PHP 8.2, so that every
// PHP version refuses the same addresses.
const NON_PUBLIC = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
'172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16',
'198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
'::/128', '::1/128', '::ffff:0:0/96', '64:ff9b:1::/48', '100::/64', '2001::/23',
'2001:db8::/32', '3fff::/20', '5f00::/16', 'fc00::/7', 'fe80::/10', 'fec0::/10', 'ff00::/8',
];
private $_allow_hosts = []; private $_allow_hosts = [];
private $_allow_cidrs = []; private $_allow_cidrs = [];
private $_resolver; private $_resolver;
@ -90,9 +102,14 @@ class Guard {
/** Whether an address is on the public internet, looking inside IPv6 forms that carry an IPv4 address. */ /** Whether an address is on the public internet, looking inside IPv6 forms that carry an IPv4 address. */
public static function is_public($address) { public static function is_public($address) {
if(!filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE))
if(!filter_var($address, FILTER_VALIDATE_IP))
return false; return false;
foreach(self::NON_PUBLIC as $cidr) {
if(self::in_cidr($address, $cidr))
return false;
}
$embedded = self::_embedded_ipv4($address); $embedded = self::_embedded_ipv4($address);
if($embedded !== null) if($embedded !== null)
return self::is_public($embedded); return self::is_public($embedded);
@ -125,13 +142,13 @@ class Guard {
return self::is_public($address); return self::is_public($address);
} }
// NAT64 (64:ff9b::/96, 64:ff9b:1::/48) and 6to4 (2002::/16) addresses
// reach an IPv4 address, which must be public too.
// NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses reach an IPv4
// address, which must be public too.
private static function _embedded_ipv4($address) { private static function _embedded_ipv4($address) {
$bin = @inet_pton($address); $bin = @inet_pton($address);
if($bin === false || strlen($bin) !== 16) if($bin === false || strlen($bin) !== 16)
return null; return null;
if(self::in_cidr($address, '64:ff9b::/96') || self::in_cidr($address, '64:ff9b:1::/48'))
if(self::in_cidr($address, '64:ff9b::/96'))
return inet_ntop(substr($bin, 12, 4)); return inet_ntop(substr($bin, 12, 4));
if(self::in_cidr($address, '2002::/16')) if(self::in_cidr($address, '2002::/16'))
return inet_ntop(substr($bin, 2, 4)); return inet_ntop(substr($bin, 2, 4));

+ 7
- 1
tests/GuardTest.php View File

@ -11,7 +11,7 @@ class GuardTest extends TestCase {
} }
public static function publicAddresses() { public static function publicAddresses() {
return [['8.8.8.8'], ['93.184.216.34'], ['2606:4700::1'], ['2001:4860:4860::8888']];
return [['8.8.8.8'], ['93.184.216.34'], ['100.128.0.1'], ['198.20.0.1'], ['223.255.255.255'], ['2606:4700::1'], ['2001:4860:4860::8888'], ['64:ff9b::808:808'], ['2002:808:808::']];
} }
public static function nonPublicAddresses() { public static function nonPublicAddresses() {
@ -19,14 +19,19 @@ class GuardTest extends TestCase {
['127.0.0.1'], ['127.8.9.10'], ['10.11.11.80'], ['172.16.0.1'], ['192.168.1.1'], ['169.254.169.254'], ['127.0.0.1'], ['127.8.9.10'], ['10.11.11.80'], ['172.16.0.1'], ['192.168.1.1'], ['169.254.169.254'],
['100.64.0.1'], ['0.0.0.0'], ['::'], ['::1'], ['fe80::1'], ['fc00::1'], ['fd12:3456::1'], ['100.64.0.1'], ['0.0.0.0'], ['::'], ['::1'], ['fe80::1'], ['fc00::1'], ['fd12:3456::1'],
['::ffff:127.0.0.1'], ['::ffff:10.0.0.1'], ['64:ff9b::7f00:1'], ['64:ff9b::a00:1'], ['2002:7f00:1::1'], ['2002:a9fe:a9fe::'], ['::ffff:127.0.0.1'], ['::ffff:10.0.0.1'], ['64:ff9b::7f00:1'], ['64:ff9b::a00:1'], ['2002:7f00:1::1'], ['2002:a9fe:a9fe::'],
['192.0.0.8'], ['192.0.2.1'], ['198.18.0.1'], ['198.19.255.255'], ['198.51.100.7'], ['203.0.113.5'], ['224.0.0.1'],
['239.255.255.250'], ['255.255.255.255'], ['100.127.255.255'], ['::ffff:8.8.8.8'], ['64:ff9b:1::1'], ['100::1'],
['2001::1'], ['2001:db8::1'], ['3fff::1'], ['fec0::1'], ['ff02::1'], ['not an address'], [''],
]; ];
} }
/** @dataProvider publicAddresses */
#[\PHPUnit\Framework\Attributes\DataProvider('publicAddresses')] #[\PHPUnit\Framework\Attributes\DataProvider('publicAddresses')]
public function testPublicAddresses($address) { public function testPublicAddresses($address) {
$this->assertTrue(Guard::is_public($address)); $this->assertTrue(Guard::is_public($address));
} }
/** @dataProvider nonPublicAddresses */
#[\PHPUnit\Framework\Attributes\DataProvider('nonPublicAddresses')] #[\PHPUnit\Framework\Attributes\DataProvider('nonPublicAddresses')]
public function testNonPublicAddresses($address) { public function testNonPublicAddresses($address) {
$this->assertFalse(Guard::is_public($address)); $this->assertFalse(Guard::is_public($address));
@ -42,6 +47,7 @@ class GuardTest extends TestCase {
]; ];
} }
/** @dataProvider blockedUrls */
#[\PHPUnit\Framework\Attributes\DataProvider('blockedUrls')] #[\PHPUnit\Framework\Attributes\DataProvider('blockedUrls')]
public function testBlockedUrls($url) { public function testBlockedUrls($url) {
$result = self::guard(['localhost.example' => ['127.0.0.1'], 'mixed.example' => ['93.184.216.34', '10.0.0.1']])->check($url); $result = self::guard(['localhost.example' => ['127.0.0.1'], 'mixed.example' => ['93.184.216.34', '10.0.0.1']])->check($url);

Loading…
Cancel
Save