From aabff9d181ffdeacfc84c2f0f1c8d24e0b8e2ffe Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Tue, 2 Jun 2026 01:02:30 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feature(strategy):=20=E2=9C=A8=20add=20NAT6?= =?UTF-8?q?4=20(RFC6052)=20embedding=20strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add embedding strategy for NAT64 "Well-Known Prefix" `64:ff9b::/96` defined by RFC 6052 § 2.1 (with test coverage and data providers). Include docblocks for the other strategies that explain their defining RFC. --- docs/05-strategies.md | 17 ++-- src/Strategy/Compatible.php | 6 ++ src/Strategy/Derived.php | 7 ++ src/Strategy/Mapped.php | 6 ++ src/Strategy/Nat64.php | 44 +++++++++ tests/DataProvider/IPv6.php | 1 + .../DataProvider/IpDataProviderInterface.php | 13 ++- tests/DataProvider/Multi.php | 9 ++ tests/DataProvider/Strategy/Nat64.php | 56 ++++++++++++ tests/Strategy/Nat64Test.php | 91 +++++++++++++++++++ tests/Version/MultiTest.php | 12 +++ 11 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 src/Strategy/Nat64.php create mode 100644 tests/DataProvider/Strategy/Nat64.php create mode 100644 tests/Strategy/Nat64Test.php diff --git a/docs/05-strategies.md b/docs/05-strategies.md index b851dac..6e8f710 100644 --- a/docs/05-strategies.md +++ b/docs/05-strategies.md @@ -1,18 +1,19 @@ # Embedding Strategies -When using version 4 and version 6 addresses interchangeably (via the +When using version 4 and version 6 addresses interchangeably (via the `Multi` class), version 4 addresses are *embedded* into version 6 addresses so that both versions are stored as 16-byte binary sequences. Unfortunately there are several different strategies for embedding a version 4 address into version 6, so this library offers various strategy implementations -for the main three: - -| Strategy Name | Implementation | Format | -|-----------------|---------------------------------|-------------------------------------------| -| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:0000:0000:0000:0000:0000` | -| IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` | -| IPv4-mapped | `Darsyn\IP\Strategy\Mapped` | `0000:0000:0000:0000:0000:ffff:XXXX:XXXX` | +for the main three (and one deprecated): + +| Strategy Name | Implementation | Format | Notes | +|-----------------|---------------------------------|-------------------------------------------|------------| +| IPv4-mapped | `Darsyn\IP\Strategy\Mapped` | `0000:0000:0000:0000:0000:ffff:XXXX:XXXX` | Default | +| NAT64 | `Darsyn\IP\Strategy\Nat64` | `0064:ff9b:0000:0000:0000:0000:XXXX:XXXX` | Translator | +| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:0000:0000:0000:0000:0000` | Relay | +| IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` | Deprecated | Each embedding strategy implements the `Darsyn\IP\Strategy\EmbeddingStrategyInterface` which defines methods to: diff --git a/src/Strategy/Compatible.php b/src/Strategy/Compatible.php index 9a6a602..8d3f73b 100644 --- a/src/Strategy/Compatible.php +++ b/src/Strategy/Compatible.php @@ -7,6 +7,12 @@ use Darsyn\IP\Exception\Strategy as StrategyException; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the IPv4-compatible prefix `::/96`, as defined + * by RFC 4291 ("IP Version 6 Addressing Architecture"), § 2.5.5.1 + * + * Note: this format is deprecated and retained only for backwards compatibility. + */ class Compatible implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool diff --git a/src/Strategy/Derived.php b/src/Strategy/Derived.php index d99503a..7b6e145 100644 --- a/src/Strategy/Derived.php +++ b/src/Strategy/Derived.php @@ -8,6 +8,13 @@ use Darsyn\IP\Util\Binary; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the 6to4 prefix `2002::/16`, as defined by RFC + * 3056 ("Connection of IPv6 Domains via IPv4 Clouds"). + * + * Legacy, but not formally deprecated (only 6to4 anycast was deprecated via + * RFC 7526). + */ class Derived implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool diff --git a/src/Strategy/Mapped.php b/src/Strategy/Mapped.php index 4d42094..ff0e56e 100644 --- a/src/Strategy/Mapped.php +++ b/src/Strategy/Mapped.php @@ -8,6 +8,12 @@ use Darsyn\IP\Util\Binary; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the IPv4-mapped prefix `::ffff:0:0/96`, as + * defined by RFC 4291 ("IP Version 6 Addressing Architecture"), § 2.5.5.2 + * + * Reserved by protocol, and not globally reachable (as an IPv6 address). + */ class Mapped implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool diff --git a/src/Strategy/Nat64.php b/src/Strategy/Nat64.php new file mode 100644 index 0000000..2ad3e09 --- /dev/null +++ b/src/Strategy/Nat64.php @@ -0,0 +1,44 @@ + self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, '2002:7f00:1::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED | self::LOOPBACK_DERIVED, '2002:1234:4321:0:00:000:0000::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, + '64:ff9b::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::NAT64 | self::LOOPBACK_NAT64, '::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE, '::12.34.56.78' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, '0::000:0000:b12:cab' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, diff --git a/tests/DataProvider/IpDataProviderInterface.php b/tests/DataProvider/IpDataProviderInterface.php index 1480f39..41e21ad 100644 --- a/tests/DataProvider/IpDataProviderInterface.php +++ b/tests/DataProvider/IpDataProviderInterface.php @@ -36,10 +36,12 @@ interface IpDataProviderInterface public const UNICAST_OTHER = 1 << 22; public const MAPPED = 1 << 23; public const DERIVED = 1 << 24; - public const COMPATIBLE = 1 << 25; - public const LOOPBACK_MAPPED = 1 << 26; - public const LOOPBACK_COMPATIBLE = 1 << 27; - public const LOOPBACK_DERIVED = 1 << 28; + public const NAT64 = 1 << 25; + public const COMPATIBLE = 1 << 26; + public const LOOPBACK_MAPPED = 1 << 27; + public const LOOPBACK_COMPATIBLE = 1 << 28; + public const LOOPBACK_DERIVED = 1 << 29; + public const LOOPBACK_NAT64 = 1 << 30; // Combinations public const PUBLIC_USE = 0 @@ -48,7 +50,8 @@ interface IpDataProviderInterface public const LOOPBACK_EMBEDDED = 0 | self::LOOPBACK_MAPPED | self::LOOPBACK_COMPATIBLE - | self::LOOPBACK_DERIVED; + | self::LOOPBACK_DERIVED + | self::LOOPBACK_NAT64; public const MULTICAST = 0 | self::MULTICAST_IPV4 | self::MULTICAST_INTERFACE_LOCAL diff --git a/tests/DataProvider/Multi.php b/tests/DataProvider/Multi.php index a0ef8fe..85c085a 100644 --- a/tests/DataProvider/Multi.php +++ b/tests/DataProvider/Multi.php @@ -239,6 +239,15 @@ public static function getDerivedLoopbackIpAddresses() ); } + /** @return list */ + public static function getNat64LoopbackIpAddresses() + { + return array_merge( + IPv4::getLoopbackIpAddresses(), + IPv6::getCategoryOfIpAddresses(IPv6::LOOPBACK | IPv6::LOOPBACK_NAT64) + ); + } + /** @return list */ public static function getMulticastIpAddresses() { diff --git a/tests/DataProvider/Strategy/Nat64.php b/tests/DataProvider/Strategy/Nat64.php new file mode 100644 index 0000000..dc3cb19 --- /dev/null +++ b/tests/DataProvider/Strategy/Nat64.php @@ -0,0 +1,56 @@ + */ + public static function getValidIpAddresses() + { + $valid = array_map(static function (array $row) { + $row[1] = true; + return $row; + }, self::getValidSequences()); + $invalid = array_map(static function (array $row) { + $row[1] = false; + return $row; + }, self::getInvalidSequences()); + return array_merge($valid, $invalid); + } + + /** @return list */ + public static function getInvalidIpAddresses() + { + return [ + [pack('H*', '20010db8000000000a608a2e037073')], + [pack('H*', '20010db8000000000a608a2e0370734556')], + ['12345678901234567'], + ['123456789012345'], + ]; + } + + /** @return list */ + public static function getValidSequences() + { + return [ + [pack('H*', '0064ff9b000000000000000000010000'), pack('H*', '00010000')], + [pack('H*', '0064ff9b00000000000000007f000001'), pack('H*', '7f000001')], + [pack('H*', '0064ff9b000000000000000012345678'), pack('H*', '12345678')], + [pack('H*', '0064ff9b00000000000000007f00a001'), pack('H*', '7f00a001')], + ]; + } + + /** @return list */ + public static function getInvalidSequences() + { + return [ + [pack('H*', '000000000000000000000fff00010000')], + [pack('H*', '00010000000000000000ffff0b120cab')], + [pack('H*', '0064ff9c00000000000000007f000001')], + [pack('H*', '00000000000000000000ffff7f00a001')], + [pack('H*', '20010db8000000000a608a2e03707334')], + ]; + } +} diff --git a/tests/Strategy/Nat64Test.php b/tests/Strategy/Nat64Test.php new file mode 100644 index 0000000..74fc4bd --- /dev/null +++ b/tests/Strategy/Nat64Test.php @@ -0,0 +1,91 @@ +strategy = new Nat64(); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getInvalidIpAddresses')] + public function testIsEmbeddedReturnsFalseForAStringOtherThan16BytesLong(string $value): void + { + $this->assertFalse($this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getValidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getValidIpAddresses')] + public function testIsEmbedded(string $value, bool $isEmbedded): void + { + $this->assertSame($isEmbedded, $this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToExtractFromStringsNot16Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\ExtractionException::class); + $this->strategy->extract($value); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getValidSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getValidSequences')] + public function testCorrectSequenceExtractedFromIpBinary(string $ipv6, string $ipv4): void + { + $this->assertSame($ipv4, $this->strategy->extract($ipv6)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToPackStringsNot4Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\PackingException::class); + $this->strategy->pack($value); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Nat64::getValidSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(Nat64DataProvider::class, 'getValidSequences')] + public function testSequenceCorrectlyPackedIntoIpBinaryFromIpBinary(string $ipv6, string $ipv4): void + { + $this->assertSame($ipv6, $this->strategy->pack($ipv4)); + } +} diff --git a/tests/Version/MultiTest.php b/tests/Version/MultiTest.php index c3f5324..16a897e 100644 --- a/tests/Version/MultiTest.php +++ b/tests/Version/MultiTest.php @@ -327,6 +327,18 @@ public function testIsLoopbackDerived(string $value, bool $isLoopback): void $this->assertSame($isLoopback, $ip->isLoopback()); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getNat64LoopbackIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getNat64LoopbackIpAddresses')] + public function testIsLoopbackNat64(string $value, bool $isLoopback): void + { + $ip = IP::factory($value, new Strategy\Nat64()); + $this->assertSame($isLoopback, $ip->isLoopback()); + } + /** * @test * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getMulticastIpAddresses() From ce3b644bee1f6f2b7da613fc136e81b45450a026 Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Tue, 2 Jun 2026 01:08:55 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feature(strategy):=20=E2=9C=A8=20add=20Comp?= =?UTF-8?q?osite=20embedding=20strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composite embedding that delegates checks/extraction to multiple underlying strategies (always uses first-defined strategy for packing). Named constructor `all()` covers all unambiguous, non-deprecated embedding, with Mapped as the canonical packer. Includes test coverage and data provider. --- docs/05-strategies.md | 56 ++++++++++++++ src/Strategy/Composite.php | 61 +++++++++++++++ tests/DataProvider/Strategy/Composite.php | 70 +++++++++++++++++ tests/Strategy/CompositeTest.php | 91 +++++++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/Strategy/Composite.php create mode 100644 tests/DataProvider/Strategy/Composite.php create mode 100644 tests/Strategy/CompositeTest.php diff --git a/docs/05-strategies.md b/docs/05-strategies.md index 6e8f710..31abf6f 100644 --- a/docs/05-strategies.md +++ b/docs/05-strategies.md @@ -41,3 +41,59 @@ IP::setDefaultEmbeddingStrategy(new Strategy\Compatible); // But for this specific instance use the 6to4-derived embedding strategy. $ip = IP::factory('127.0.0.1', new Strategy\Derived); ``` + +## Composite + +The `Composite` strategy accepts one or more embedding strategies to verify +address embedding against. But only the first strategy supplied is used to embed +IPv4 into IPv6 (the "packer"). + +```php +getDotAddress(); // string("127.0.0.1") +$nat64 = IP::factory('64:ff9b::7f00:1', $strategy); +$nat64->getDotAddress(); // string("127.0.0.1") + +// But only the first argument (in this example, Mapped) is used to pack an +// IPv4 address into IPv6. +IP::factory('127.0.0.1', $strategy)->getCompactedAddress(); // string("::ffff:7f00:1") +// Existing IPv6 addresses retain their scheme and don't get "re-packed" into +// the first strategy. +IP::factory('64:ff9b::7f00:1', $strategy)->getCompactedAddress(); // string("64:ff9b::7f00:1") +``` + +### Named Constructor + +`Composite::all()` returns a composite of every **unambiguous, non-deprecated** +strategy: +- IPv4-mapped (`::ffff:0:0/96`) as the packer, +- 6to4 (`2002::/16`), and +- the NAT64 Well-Known Prefix (`64:ff9b::/96`). + +```php +getProtocolAppropriateAddress(); // string("127.0.0.1") +IP::factory('2002:7f00:1::', $strategy)->getProtocolAppropriateAddress(); // string("127.0.0.1") +IP::factory('64:ff9b::7f00:1', $strategy)->getProtocolAppropriateAddress(); // string("127.0.0.1") +``` + +> **Note:** `all()` deliberately **excludes** the ambiguous, deprecated +> IPv4-compatible (`Compatible`, `::/96`) strategy. diff --git a/src/Strategy/Composite.php b/src/Strategy/Composite.php new file mode 100644 index 0000000..214aae5 --- /dev/null +++ b/src/Strategy/Composite.php @@ -0,0 +1,61 @@ + */ + private $strategies; + + /** @var EmbeddingStrategyInterface */ + private $packer; + + public function __construct( + EmbeddingStrategyInterface $packer, + EmbeddingStrategyInterface ...$additional + ) { + $this->packer = $packer; + $this->strategies = array_values(array_merge([$packer], $additional)); + } + + /** + * Named helper constructor for matching all unambiguous, non-deprecated + * embedding strategies. + */ + public static function all(): self + { + // Explicitly NOT including the ambiguous, deprecated "Compatible" + // embedding strategy, and using Mapped as the canonical strategy for + // packing. + return new self(new Mapped(), new Derived(), new Nat64()); + } + + public function isEmbedded(string $binary): bool + { + foreach ($this->strategies as $strategy) { + if ($strategy->isEmbedded($binary)) { + return true; + } + } + return false; + } + + public function extract(string $binary): string + { + foreach ($this->strategies as $strategy) { + if ($strategy->isEmbedded($binary)) { + return $strategy->extract($binary); + } + } + throw new StrategyException\ExtractionException($binary, $this); + } + + public function pack(string $binary): string + { + return $this->packer->pack($binary); + } +} diff --git a/tests/DataProvider/Strategy/Composite.php b/tests/DataProvider/Strategy/Composite.php new file mode 100644 index 0000000..eb74ed6 --- /dev/null +++ b/tests/DataProvider/Strategy/Composite.php @@ -0,0 +1,70 @@ + */ + public static function getValidIpAddresses() + { + $valid = array_map(static function (array $row) { + $row[1] = true; + return $row; + }, self::getValidSequences()); + $invalid = array_map(static function (array $row) { + $row[1] = false; + return $row; + }, self::getInvalidSequences()); + return array_merge($valid, $invalid); + } + + /** @return list */ + public static function getInvalidIpAddresses() + { + // Strings that are neither 4 nor 16 bytes are rejected identically by + // every embedding strategy. + return Mapped::getInvalidIpAddresses(); + } + + /** + * Composite::all() recognises the Mapped, Derived (6to4) and NAT64 + * embeddings, so its valid sequences are the union of all three. + * + * @return list + */ + public static function getValidSequences() + { + return array_merge( + Mapped::getValidSequences(), + Derived::getValidSequences(), + Nat64::getValidSequences() + ); + } + + /** + * Composite::all() deliberately excludes the deprecated "Compatible" + * embedding, so addresses embedded only under that scheme must NOT be + * recognised by the composite. + * + * @return list + */ + public static function getInvalidSequences() + { + return array_map(static function (array $row) { + return [$row[0]]; + }, Compatible::getValidSequences()); + } + + /** + * Composite always packs using its canonical (Mapped) strategy, so only + * Mapped sequences round-trip through pack(). + * + * @return list + */ + public static function getPackableSequences() + { + return Mapped::getValidSequences(); + } +} diff --git a/tests/Strategy/CompositeTest.php b/tests/Strategy/CompositeTest.php new file mode 100644 index 0000000..0f59f39 --- /dev/null +++ b/tests/Strategy/CompositeTest.php @@ -0,0 +1,91 @@ +strategy = Composite::all(); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getInvalidIpAddresses')] + public function testIsEmbeddedReturnsFalseForAStringOtherThan16BytesLong(string $value): void + { + $this->assertFalse($this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getValidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getValidIpAddresses')] + public function testIsEmbedded(string $value, bool $isEmbedded): void + { + $this->assertSame($isEmbedded, $this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToExtractFromStringsNot16Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\ExtractionException::class); + $this->strategy->extract($value); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getValidSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getValidSequences')] + public function testCorrectSequenceExtractedFromIpBinary(string $ipv6, string $ipv4): void + { + $this->assertSame($ipv4, $this->strategy->extract($ipv6)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToPackStringsNot4Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\PackingException::class); + $this->strategy->pack($value); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Composite::getPackableSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(CompositeDataProvider::class, 'getPackableSequences')] + public function testSequenceCorrectlyPackedIntoIpBinaryFromIpBinary(string $ipv6, string $ipv4): void + { + $this->assertSame($ipv6, $this->strategy->pack($ipv4)); + } +} From 11b3b7808f06eb788bda9120c4f119dcb44418bb Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Tue, 2 Jun 2026 01:19:55 +0200 Subject: [PATCH 3/6] =?UTF-8?q?bugfix(ipv6):=20=F0=9F=90=9B=20classify=20I?= =?UTF-8?q?Pv4-embedded=20IPv6=20by=20embedded=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isUnicastGlobal() only inspected 16-byte IPv6 form, meaning a non-global IPv4-embedded address (loopback, private, etc) was reported as global. Error also cascaded down to isPublicUse(). Bug could potentially allow a non-global address to slip past an SSRF/ACL guard/deny-list. Embedded addresses are now canonicalised before classifying. Note: IPv6 now checks ALL valid IPv4-embeddings (previously only Multi checked, and only checked its singularly-assigned embedding). --- src/Version/IPv6.php | 10 ++++++++++ tests/DataProvider/IPv6.php | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Version/IPv6.php b/src/Version/IPv6.php index 0be944d..99a4470 100644 --- a/src/Version/IPv6.php +++ b/src/Version/IPv6.php @@ -6,6 +6,7 @@ use Darsyn\IP\AbstractIP; use Darsyn\IP\Exception; +use Darsyn\IP\Strategy\Composite; use Darsyn\IP\Strategy\EmbeddingStrategyInterface; use Darsyn\IP\Util\Binary; use Darsyn\IP\Util\MbString; @@ -142,6 +143,15 @@ public function isUnicast(): bool public function isUnicastGlobal(): bool { + // An IPv6 address that embeds an IPv4 address is only globally reachable + // if the address it actually stands for is; canonicalise before + // classifying. The deprecated IPv4-compatible embedding is deliberately + // excluded (see Composite::all()), so "::/96" addresses are still + // classified as plain IPv6. + $strategy = Composite::all(); + if ($strategy->isEmbedded($this->getBinary())) { + return (new IPv4($strategy->extract($this->getBinary())))->isPublicUse(); + } return $this->isUnicast() && !$this->isLoopback() && !$this->isLinkLocal() diff --git a/tests/DataProvider/IPv6.php b/tests/DataProvider/IPv6.php index 3ac2d02..011d0fd 100644 --- a/tests/DataProvider/IPv6.php +++ b/tests/DataProvider/IPv6.php @@ -293,14 +293,20 @@ public static function getCategorizedIpAddresses() 'fd00::' => self::PRIVATE_USE | self::UNIQUE_LOCAL | self::UNICAST_OTHER, 'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => self::PRIVATE_USE | self::UNIQUE_LOCAL | self::UNICAST_OTHER, 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => self::MULTICAST_OTHER, - '::ffff:1:0' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::MAPPED, - '::ffff:7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::MAPPED | self::LOOPBACK_MAPPED, + // An IPv4-embedded address is only globally routable if the address + // it stands for is; the embedded loopback / "this-network" rows below + // are therefore unicast-but-not-global (see IPv6::isUnicastGlobal()). + '::ffff:1:0' => self::UNICAST_OTHER | self::MAPPED, + '::ffff:7f00:1' => self::UNICAST_OTHER | self::MAPPED | self::LOOPBACK_MAPPED, '::ffff:1234:5678' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::MAPPED, - '0000:0000:0000:0000:0000:ffff:7f00:a001' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::MAPPED | self::LOOPBACK_MAPPED, - '2002::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, - '2002:7f00:1::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED | self::LOOPBACK_DERIVED, + '0000:0000:0000:0000:0000:ffff:7f00:a001' => self::UNICAST_OTHER | self::MAPPED | self::LOOPBACK_MAPPED, + '2002::' => self::UNICAST_OTHER | self::DERIVED, + '2002:7f00:1::' => self::UNICAST_OTHER | self::DERIVED | self::LOOPBACK_DERIVED, '2002:1234:4321:0:00:000:0000::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, - '64:ff9b::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::NAT64 | self::LOOPBACK_NAT64, + // NAT64 (64:ff9b::/96): excluded from the Compatible-only ambiguity, + // recognised by Composite::all(), so it canonicalises like the others. + '64:ff9b::1234:5678' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL, + '64:ff9b::7f00:1' => self::UNICAST_OTHER | self::NAT64 | self::LOOPBACK_NAT64, '::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE, '::12.34.56.78' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, '0::000:0000:b12:cab' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, From 1627e36df4288fd9d832aa86e79fe90df286be4d Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Thu, 4 Jun 2026 01:11:56 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feature(strategy):=20=E2=9C=A8=20add=20Tere?= =?UTF-8?q?do=20(RFC4380)=20embedding=20strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extraction-only embedding strategy for the Teredo tunnelling prefix `2001::/32` defined by RFC 4380 § 4 (with test coverage and data providers). --- docs/05-strategies.md | 25 ++--- src/Strategy/Composite.php | 2 +- src/Strategy/Teredo.php | 51 ++++++++++ tests/DataProvider/IPv6.php | 3 + .../DataProvider/IpDataProviderInterface.php | 5 +- tests/DataProvider/Multi.php | 9 ++ tests/DataProvider/Strategy/Composite.php | 7 +- tests/DataProvider/Strategy/Teredo.php | 67 +++++++++++++ tests/Strategy/TeredoTest.php | 96 +++++++++++++++++++ tests/Version/MultiTest.php | 25 +++++ 10 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 src/Strategy/Teredo.php create mode 100644 tests/DataProvider/Strategy/Teredo.php create mode 100644 tests/Strategy/TeredoTest.php diff --git a/docs/05-strategies.md b/docs/05-strategies.md index 31abf6f..e4db204 100644 --- a/docs/05-strategies.md +++ b/docs/05-strategies.md @@ -6,14 +6,15 @@ that both versions are stored as 16-byte binary sequences. Unfortunately there are several different strategies for embedding a version 4 address into version 6, so this library offers various strategy implementations -for the main three (and one deprecated): +for the main four (and one deprecated): -| Strategy Name | Implementation | Format | Notes | -|-----------------|---------------------------------|-------------------------------------------|------------| -| IPv4-mapped | `Darsyn\IP\Strategy\Mapped` | `0000:0000:0000:0000:0000:ffff:XXXX:XXXX` | Default | -| NAT64 | `Darsyn\IP\Strategy\Nat64` | `0064:ff9b:0000:0000:0000:0000:XXXX:XXXX` | Translator | -| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:0000:0000:0000:0000:0000` | Relay | -| IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` | Deprecated | +| Strategy Name | Implementation | Format | Notes | +|-----------------|---------------------------------|-------------------------------------------|-----------------------| +| IPv4-mapped | `Darsyn\IP\Strategy\Mapped` | `0000:0000:0000:0000:0000:ffff:XXXX:XXXX` | Default | +| NAT64 | `Darsyn\IP\Strategy\Nat64` | `0064:ff9b:0000:0000:0000:0000:XXXX:XXXX` | Translator | +| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:0000:0000:0000:0000:0000` | Relay | +| Teredo | `Darsyn\IP\Strategy\Teredo` | `2001:0000:xxxx:xxxx:xxxx:xxxx:XXXX:XXXX` | Tunnel (extract-only) | +| IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` | Deprecated | Each embedding strategy implements the `Darsyn\IP\Strategy\EmbeddingStrategyInterface` which defines methods to: @@ -77,22 +78,24 @@ IP::factory('64:ff9b::7f00:1', $strategy)->getCompactedAddress(); // string("64: `Composite::all()` returns a composite of every **unambiguous, non-deprecated** strategy: - IPv4-mapped (`::ffff:0:0/96`) as the packer, -- 6to4 (`2002::/16`), and -- the NAT64 Well-Known Prefix (`64:ff9b::/96`). +- 6to4 (`2002::/16`), +- the NAT64 Well-Known Prefix (`64:ff9b::/96`), and +- Teredo (`2001::/32`). ```php getProtocolAppropriateAddress(); // string("127.0.0.1") IP::factory('2002:7f00:1::', $strategy)->getProtocolAppropriateAddress(); // string("127.0.0.1") IP::factory('64:ff9b::7f00:1', $strategy)->getProtocolAppropriateAddress(); // string("127.0.0.1") +IP::factory('2001:0:4136:e378:8000:63bf:80ff:fffe', $strategy)->getProtocolAppropriateAddress(); // string("127.0.0.1") ``` > **Note:** `all()` deliberately **excludes** the ambiguous, deprecated diff --git a/src/Strategy/Composite.php b/src/Strategy/Composite.php index 214aae5..eeee9ec 100644 --- a/src/Strategy/Composite.php +++ b/src/Strategy/Composite.php @@ -31,7 +31,7 @@ public static function all(): self // Explicitly NOT including the ambiguous, deprecated "Compatible" // embedding strategy, and using Mapped as the canonical strategy for // packing. - return new self(new Mapped(), new Derived(), new Nat64()); + return new self(new Mapped(), new Derived(), new Nat64(), new Teredo()); } public function isEmbedded(string $binary): bool diff --git a/src/Strategy/Teredo.php b/src/Strategy/Teredo.php new file mode 100644 index 0000000..c1fed3c --- /dev/null +++ b/src/Strategy/Teredo.php @@ -0,0 +1,51 @@ + self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL, '64:ff9b::7f00:1' => self::UNICAST_OTHER | self::NAT64 | self::LOOPBACK_NAT64, + // Teredo (2001::/32). + '2001:0:4136:e378:8000:63bf:edcb:a987' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL, + '2001:0:4136:e378:8000:63bf:80ff:fffe' => self::UNICAST_OTHER | self::TEREDO | self::LOOPBACK_TEREDO, '::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE, '::12.34.56.78' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, '0::000:0000:b12:cab' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, diff --git a/tests/DataProvider/IpDataProviderInterface.php b/tests/DataProvider/IpDataProviderInterface.php index 41e21ad..f88d9be 100644 --- a/tests/DataProvider/IpDataProviderInterface.php +++ b/tests/DataProvider/IpDataProviderInterface.php @@ -42,6 +42,8 @@ interface IpDataProviderInterface public const LOOPBACK_COMPATIBLE = 1 << 28; public const LOOPBACK_DERIVED = 1 << 29; public const LOOPBACK_NAT64 = 1 << 30; + public const TEREDO = 1 << 31; + public const LOOPBACK_TEREDO = 1 << 32; // Combinations public const PUBLIC_USE = 0 @@ -51,7 +53,8 @@ interface IpDataProviderInterface | self::LOOPBACK_MAPPED | self::LOOPBACK_COMPATIBLE | self::LOOPBACK_DERIVED - | self::LOOPBACK_NAT64; + | self::LOOPBACK_NAT64 + | self::LOOPBACK_TEREDO; public const MULTICAST = 0 | self::MULTICAST_IPV4 | self::MULTICAST_INTERFACE_LOCAL diff --git a/tests/DataProvider/Multi.php b/tests/DataProvider/Multi.php index 85c085a..6af6ad2 100644 --- a/tests/DataProvider/Multi.php +++ b/tests/DataProvider/Multi.php @@ -248,6 +248,15 @@ public static function getNat64LoopbackIpAddresses() ); } + /** @return list */ + public static function getTeredoLoopbackIpAddresses() + { + // Teredo is an extraction-only strategy (pack() always throws), so + // IPv4 addresses cannot be merged in like the other strategies above: + // Multi::factory() would fail trying to pack them into 16 bytes. + return IPv6::getCategoryOfIpAddresses(IPv6::LOOPBACK | IPv6::LOOPBACK_TEREDO); + } + /** @return list */ public static function getMulticastIpAddresses() { diff --git a/tests/DataProvider/Strategy/Composite.php b/tests/DataProvider/Strategy/Composite.php index eb74ed6..348608a 100644 --- a/tests/DataProvider/Strategy/Composite.php +++ b/tests/DataProvider/Strategy/Composite.php @@ -29,8 +29,8 @@ public static function getInvalidIpAddresses() } /** - * Composite::all() recognises the Mapped, Derived (6to4) and NAT64 - * embeddings, so its valid sequences are the union of all three. + * Composite::all() recognises the Mapped, Derived (6to4), NAT64 and + * Teredo embeddings, so its valid sequences are the union of all four. * * @return list */ @@ -39,7 +39,8 @@ public static function getValidSequences() return array_merge( Mapped::getValidSequences(), Derived::getValidSequences(), - Nat64::getValidSequences() + Nat64::getValidSequences(), + Teredo::getValidSequences() ); } diff --git a/tests/DataProvider/Strategy/Teredo.php b/tests/DataProvider/Strategy/Teredo.php new file mode 100644 index 0000000..7900033 --- /dev/null +++ b/tests/DataProvider/Strategy/Teredo.php @@ -0,0 +1,67 @@ + */ + public static function getValidIpAddresses() + { + $valid = array_map(static function (array $row) { + $row[1] = true; + return $row; + }, self::getValidSequences()); + $invalid = array_map(static function (array $row) { + $row[1] = false; + return $row; + }, self::getInvalidSequences()); + return array_merge($valid, $invalid); + } + + /** @return list */ + public static function getInvalidIpAddresses() + { + return [ + [pack('H*', '20010db8000000000a608a2e037073')], + [pack('H*', '20010db8000000000a608a2e0370734556')], + ['12345678901234567'], + ['123456789012345'], + ]; + } + + /** + * The client's public IPv4 address sits in the last 4 bytes XOR'd with + * `0xffffffff`, so the extracted sequence is the bitwise NOT of the last + * 4 bytes of the IPv6 address. + * + * @return list + */ + public static function getValidSequences() + { + return [ + // RFC 4380's worked example: server 65.54.227.120, port 40000, + // client 192.0.2.45. + [pack('H*', '200100004136e378800063bf3ffffdd2'), pack('H*', 'c000022d')], + [pack('H*', '200100004136e378800063bf80fffffe'), pack('H*', '7f000001')], + [pack('H*', '200100004136e378800063bfedcba987'), pack('H*', '12345678')], + // Degenerate all-zero server, flags, and port. + [pack('H*', '20010000000000000000000080fffffe'), pack('H*', '7f000001')], + ]; + } + + /** @return list */ + public static function getInvalidSequences() + { + return [ + // Benchmarking (2001:2::/48), just outside the Teredo /32. + [pack('H*', '20010002000000000000000000000001')], + // Documentation (2001:db8::/32). + [pack('H*', '20010db8000000000a608a2e03707334')], + [pack('H*', '00000000000000000000ffff7f000001')], + [pack('H*', '0064ff9b00000000000000007f000001')], + [pack('H*', '20027f00000100000000000000000000')], + ]; + } +} diff --git a/tests/Strategy/TeredoTest.php b/tests/Strategy/TeredoTest.php new file mode 100644 index 0000000..a3763ea --- /dev/null +++ b/tests/Strategy/TeredoTest.php @@ -0,0 +1,96 @@ +strategy = new Teredo(); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')] + public function testIsEmbeddedReturnsFalseForAStringOtherThan16BytesLong(string $value): void + { + $this->assertFalse($this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidIpAddresses')] + public function testIsEmbedded(string $value, bool $isEmbedded): void + { + $this->assertSame($isEmbedded, $this->strategy->isEmbedded($value)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToExtractFromStringsNot16Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\ExtractionException::class); + $this->strategy->extract($value); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidSequences')] + public function testCorrectSequenceExtractedFromIpBinary(string $ipv6, string $ipv4): void + { + $this->assertSame($ipv4, $this->strategy->extract($ipv6)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')] + public function testExceptionIsThrownWhenTryingToPackStringsNot4Bytes(string $value): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\PackingException::class); + $this->strategy->pack($value); + } + + /** + * Teredo is an extraction-only strategy: a Teredo address cannot be + * constructed from an IPv4 address alone, so pack() throws even for valid + * 4-byte sequences. + * + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidSequences')] + public function testExceptionIsThrownWhenTryingToPackValidSequences(string $ipv6, string $ipv4): void + { + $this->expectException(\Darsyn\IP\Exception\Strategy\PackingException::class); + $this->strategy->pack($ipv4); + } +} diff --git a/tests/Version/MultiTest.php b/tests/Version/MultiTest.php index 16a897e..44d5381 100644 --- a/tests/Version/MultiTest.php +++ b/tests/Version/MultiTest.php @@ -339,6 +339,31 @@ public function testIsLoopbackNat64(string $value, bool $isLoopback): void $this->assertSame($isLoopback, $ip->isLoopback()); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getTeredoLoopbackIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getTeredoLoopbackIpAddresses')] + public function testIsLoopbackTeredo(string $value, bool $isLoopback): void + { + $ip = IP::factory($value, new Strategy\Teredo()); + $this->assertSame($isLoopback, $ip->isLoopback()); + } + + /** + * Teredo is an extraction-only strategy, so an IPv4 address cannot be + * packed into a Multi instance with it. + * + * @test + */ + #[PHPUnit\Test] + public function testExceptionIsThrownWhenInstantiatingFromIpv4WithTeredoStrategy(): void + { + $this->expectException(InvalidIpAddressException::class); + IP::factory('127.0.0.1', new Strategy\Teredo()); + } + /** * @test * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getMulticastIpAddresses() From 456af9e98bf94dabad7bed0cf0c47efed60d2ba8 Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Thu, 4 Jun 2026 01:18:15 +0200 Subject: [PATCH 5/6] =?UTF-8?q?bugfix(ipv6):=20=F0=9F=90=9B=20classify=20N?= =?UTF-8?q?AT64=20local-use=20block=20as=20not=20global?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IANA special-purpose registry lists the NAT64 local-use block `64:ff9b:1::/48` (defined by RFC 8215; different to the NAT64 well-known prefix) as not globally reachable, but `isUnicastGlobal()` and `isPublicUse()` report addresses within it as global. Fix: explicitly exlcude them. Operator-defined, cannot be decoded by an embedding strategy, must be excluded as a whole instead of being canonicalised. --- src/Version/IPv6.php | 13 ++++++++++++- tests/DataProvider/IPv6.php | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Version/IPv6.php b/src/Version/IPv6.php index 99a4470..dc4d2d4 100644 --- a/src/Version/IPv6.php +++ b/src/Version/IPv6.php @@ -157,7 +157,18 @@ public function isUnicastGlobal(): bool && !$this->isLinkLocal() && !$this->isUniqueLocal() && !$this->isUnspecified() - && !$this->isDocumentation(); + && !$this->isDocumentation() + && !$this->isNat64LocalUse(); + } + + /** The IANA special-purpose registry lists `64:ff9b:1::/48` as not globally reachable. */ + private function isNat64LocalUse(): bool + { + // RFC 8215 reserves the /48 block for local use, but operators subdivide + // it into Network-Specific Prefixes of any RFC 6052 length (/48, /56, + // /64, /96…), and each length places the IPv4 bytes at a different offset + // when embedding. + return $this->inRange(new self(Binary::fromHex('0064ff9b000100000000000000000000')), 48); } public function __toString(): string diff --git a/tests/DataProvider/IPv6.php b/tests/DataProvider/IPv6.php index 0e33fe0..2dd3896 100644 --- a/tests/DataProvider/IPv6.php +++ b/tests/DataProvider/IPv6.php @@ -310,6 +310,10 @@ public static function getCategorizedIpAddresses() // Teredo (2001::/32). '2001:0:4136:e378:8000:63bf:edcb:a987' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL, '2001:0:4136:e378:8000:63bf:80ff:fffe' => self::UNICAST_OTHER | self::TEREDO | self::LOOPBACK_TEREDO, + // NAT64 local-use (64:ff9b:1::/48, RFC 8215): never globally reachable. + '64:ff9b:1::1' => self::UNICAST_OTHER, + '64:ff9b:1:ffff:ffff:ffff:ffff:ffff' => self::UNICAST_OTHER, + '64:ff9b:2::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL, '::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE, '::12.34.56.78' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, '0::000:0000:b12:cab' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, From 026a62ad1853a4b0b741fd8f72174cc1c35ac9bb Mon Sep 17 00:00:00 2001 From: Zan Baldwin Date: Thu, 4 Jun 2026 01:19:05 +0200 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=F0=9F=93=9A=20update=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978643..26f3bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ - Bugfix: classify full `fc00::/7` block as private use. - Add (or update) community standards: `SECURITY.md`, `CODE_OF_CONDUCT.md` and `CONTRIBUTING.md` +- Add `Nat64` embedding strategy for the NAT64 well-known prefix `64:ff9b::/96` + (according to RFC 6052 § `2.1`). +- Add `Composite` embedding strategy that delegates to multiple underlying + embedding strategies; `Composite::all()` covers all unambiguous, + non-deprecated embeddings. +- Bugfix: classify IPv4-embedded IPv6 addresses (mapped, 6to4, NAT64) according + to the IPv4 address they embed in `isUnicastGlobal()` and `isPublicUse()`, + preventing non-global embedded addresses from being reported as global. + Inspired by [CVE-2026-48736](https://symfony.com/blog/cve-2026-48736-iputils-private-subnets-omits-ipv6-transition-forms-ssrf-bypass-in-noprivatenetworkhttpclient). +- Add `Teredo` embedding strategy for `2001::/32` (according to RFC 4380 § `4`, + extraction-only). Classification now also canonicalises Teredo embeddings via + `Composite::all()`. +- Classify the NAT64 local-use block `64:ff9b:1::/48` (according to RFC 8215) as + not globally reachable. ## `6.0.0`