diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 64c2d38f2de2f..ce1b339392bf0 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -4,7 +4,8 @@ CHANGELOG 7.2 --- - * Add optional `$requests` argument to `RequestStack::__construct()` + * Add optional `$requests` parameter to `RequestStack::__construct()` + * Add optional `$v4Bytes` and `$v6Bytes` parameters to `IpUtils::anonymize()` 7.1 --- diff --git a/src/Symfony/Component/HttpFoundation/IpUtils.php b/src/Symfony/Component/HttpFoundation/IpUtils.php index db2c8efdcc505..5e1e29c9585f0 100644 --- a/src/Symfony/Component/HttpFoundation/IpUtils.php +++ b/src/Symfony/Component/HttpFoundation/IpUtils.php @@ -178,25 +178,47 @@ public static function checkIp6(string $requestIp, string $ip): bool /** * Anonymizes an IP/IPv6. * - * Removes the last byte for v4 and the last 8 bytes for v6 IPs + * Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default). + * + * @param int<0, 4> $v4Bytes + * @param int<0, 16> $v6Bytes */ - public static function anonymize(string $ip): string + public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string { + $v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1; + $v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8; + + if ($v4Bytes < 0 || $v6Bytes < 0) { + throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.'); + } + + if ($v4Bytes > 4 || $v6Bytes > 16) { + throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + } + $wrappedIPv6 = false; if (str_starts_with($ip, '[') && str_ends_with($ip, ']')) { $wrappedIPv6 = true; $ip = substr($ip, 1, -1); } + $mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) { + $mask .= str_repeat('ff', 4 - $bytesToAnonymize); + $mask .= str_repeat('00', $bytesToAnonymize); + + return '::'.implode(':', str_split($mask, 4)); + }; + $packedAddress = inet_pton($ip); if (4 === \strlen($packedAddress)) { - $mask = '255.255.255.0'; + $mask = rtrim(str_repeat('255.', 4 - $v4Bytes).str_repeat('0.', $v4Bytes), '.'); } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) { - $mask = '::ffff:ffff:ff00'; + $mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes); } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) { - $mask = '::ffff:ff00'; + $mask = $mappedIpV4MaskGenerator('', $v4Bytes); } else { - $mask = 'ffff:ffff:ffff:ffff:0000:0000:0000:0000'; + $mask = str_repeat('ff', 16 - $v6Bytes).str_repeat('00', $v6Bytes); + $mask = implode(':', str_split($mask, 4)); } $ip = inet_ntop($packedAddress & inet_pton($mask)); diff --git a/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php b/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php index ce93c69e90043..95044106ba0e6 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php @@ -150,6 +150,70 @@ public static function anonymizedIpData() ]; } + /** + * @dataProvider anonymizedIpDataWithBytes + */ + public function testAnonymizeWithBytes($ip, $expected, $bytesForV4, $bytesForV6) + { + $this->assertSame($expected, IpUtils::anonymize($ip, $bytesForV4, $bytesForV6)); + } + + public static function anonymizedIpDataWithBytes(): array + { + return [ + ['192.168.1.1', '192.168.0.0', 2, 8], + ['192.168.1.1', '192.0.0.0', 3, 8], + ['192.168.1.1', '0.0.0.0', 4, 8], + ['1.2.3.4', '1.2.3.0', 1, 8], + ['1.2.3.4', '1.2.3.4', 0, 8], + ['2a01:198:603:0:396e:4789:8e99:890f', '2a01:198:603:0:396e:4789:8e99:890f', 1, 0], + ['2a01:198:603:0:396e:4789:8e99:890f', '2a01:198:603:0:396e:4789::', 1, 4], + ['2a01:198:603:10:396e:4789:8e99:890f', '2a01:198:603:10:396e:4700::', 1, 5], + ['2a01:198:603:10:396e:4789:8e99:890f', '2a00::', 1, 15], + ['2a01:198:603:10:396e:4789:8e99:890f', '::', 1, 16], + ['::1', '::', 1, 1], + ['0:0:0:0:0:0:0:1', '::', 1, 1], + ['1:0:0:0:0:0:0:1', '1::', 1, 1], + ['0:0:603:50:396e:4789:8e99:0001', '0:0:603::', 1, 10], + ['[0:0:603:50:396e:4789:8e99:0001]', '[::603:50:396e:4789:8e00:0]', 1, 3], + ['[2a01:198::3]', '[2a01:198::]', 1, 2], + ['::ffff:123.234.235.236', '::ffff:123.234.235.0', 1, 8], // IPv4-mapped IPv6 addresses + ['::123.234.235.236', '::123.234.0.0', 2, 8], // deprecated IPv4-compatible IPv6 address + ]; + } + + public function testAnonymizeV4WithNegativeBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize less than 0 bytes.'); + + IpUtils::anonymize('anything', -1, 8); + } + + public function testAnonymizeV6WithNegativeBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize less than 0 bytes.'); + + IpUtils::anonymize('anything', 1, -1); + } + + public function testAnonymizeV4WithTooManyBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + + IpUtils::anonymize('anything', 5, 8); + } + + public function testAnonymizeV6WithTooManyBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + + IpUtils::anonymize('anything', 1, 17); + } + /** * @dataProvider getIp4SubnetMaskZeroData */