Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
74 changes: 67 additions & 7 deletions docs/05-strategies.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# 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:
for the main four (and one deprecated):

| 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` |
| 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:
Expand Down Expand Up @@ -40,3 +42,61 @@ 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
<?php
use Darsyn\IP\Strategy\Composite;
use Darsyn\IP\Strategy\Mapped;
use Darsyn\IP\Strategy\Nat64;
use Darsyn\IP\Version\Multi as IP;

// Recognise both IPv4-mapped and NAT64 embeddings; pack as IPv4-mapped.
$strategy = new Composite(new Mapped, new Nat64);

// Both Mapped and NAT64 embedded addresses are recognised as IPv4.
$mapped = IP::factory('::ffff:7f00:1', $strategy);
$mapped->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`),
- the NAT64 Well-Known Prefix (`64:ff9b::/96`), and
- Teredo (`2001::/32`).

```php
<?php
use Darsyn\IP\Strategy\Composite;
use Darsyn\IP\Version\Multi as IP;

// Equivalent to: new Composite(new Mapped, new Derived, new Nat64, new Teredo).
$strategy = Composite::all();

// An address embedded under any of the four schemes is recognised and
// resolves to the same version 4 address.
IP::factory('::ffff:7f00:1', $strategy)->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
> IPv4-compatible (`Compatible`, `::/96`) strategy.
6 changes: 6 additions & 0 deletions src/Strategy/Compatible.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/Strategy/Composite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Strategy;

use Darsyn\IP\Exception\Strategy as StrategyException;

class Composite implements EmbeddingStrategyInterface
{
/** @var list<EmbeddingStrategyInterface> */
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(), new Teredo());
}

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);
}
}
7 changes: 7 additions & 0 deletions src/Strategy/Derived.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Strategy/Mapped.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions src/Strategy/Nat64.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Strategy;

use Darsyn\IP\Exception\Strategy as StrategyException;
use Darsyn\IP\Util\Binary;
use Darsyn\IP\Util\MbString;

/**
* Embeds an IPv4 address within the "Well-Known Prefix" `64:ff9b::/96`, as
* defined by RFC 6052 ("IPv6 Addressing of IPv4/IPv6 Translators"), § 2.1
*
* Globally reachable, but not reserved by protocol. RFC 6052 § 3.1 states that
* non-global IPv4 addresses must NOT be embedded as NAT64.
*/
class Nat64 implements EmbeddingStrategyInterface
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since NAT64 supports prefixes other than 64:ff9b::/96, would it be possible to add the option to specify a different checksum-neutral prefix for NAT64?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been reading RFC specifications for nearly 2 days, so my brain needs a rest right now, but I have seen this and I will reply some time in the next couple of days 👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about adding named constructors to NAT64:

  • Nat64::wellKnown() (default, part of Composite::all()), and
  • Nat64::networkSpecific(IPv6 $prefix, int $cidr) where $cidr must be one of 32, 40, 48, 56, 64, or 96.
  • Also something about NAT64 local-use, because that entire block is defined as not globally reachable, but not sure where that logic is going to go (just made a private helper method in the other pull requests).

Will probably cancel this pull request, and do just the NAT64 stuff on its own in a new pull request on Monday. Will tag you in it for review (unless you leave any comments here before then).

Thanks for the suggestion!

{
public function isEmbedded(string $binary): bool
{
return 16 === MbString::getLength($binary)
&& MbString::subString($binary, 0, 12) === Binary::fromHex('0064ff9b0000000000000000');
}

public function extract(string $binary): string
{
if (16 === MbString::getLength($binary)) {
return MbString::subString($binary, 12, 4);
}
throw new StrategyException\ExtractionException($binary, $this);
}

public function pack(string $binary): string
{
// Note: non-global IPv4 addresses should not be packed into NAT64, but
// is not enforced here. It is down to the user of this library to know
// when to use which embedding strategy.
if (4 === MbString::getLength($binary)) {
return Binary::fromHex('0064ff9b0000000000000000') . $binary;
}
throw new StrategyException\PackingException($binary, $this);
}
}
51 changes: 51 additions & 0 deletions src/Strategy/Teredo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Strategy;

use Darsyn\IP\Exception\Strategy as StrategyException;
use Darsyn\IP\Util\Binary;
use Darsyn\IP\Util\MbString;

/**
* Extracts an IPv4 address from the Teredo prefix `2001::/32`, as defined by
* RFC 4380 ("Teredo: Tunneling IPv6 over UDP through NATs"), § 4.
*
* A Teredo address encodes the tunnel server's IPv4 address (bits 32–63), flags
* (bits 64–79), the client's obfuscated UDP port (bits 80–95, XOR'd with
* `0xFFFF`), and the client's obfuscated public IPv4 address (bits 96–127,
* XOR'd with `0xFFFFFFFF`). This strategy extracts the client address (the
* address the Teredo address stands for).
*/
class Teredo implements EmbeddingStrategyInterface
{
public function isEmbedded(string $binary): bool
{
return 16 === MbString::getLength($binary)
&& MbString::subString($binary, 0, 4) === Binary::fromHex('20010000');
}

public function extract(string $binary): string
{
if (16 === MbString::getLength($binary)) {
// The client's public IPv4 sits in the last 4 bytes XOR'd with 0xFFFFFFFF.
return MbString::subString($binary, 12, 4) ^ "\xff\xff\xff\xff";
}
throw new StrategyException\ExtractionException($binary, $this);
}

public function pack(string $binary): string
{
// Per RFC 4380 the embedded client address is the public, post-NAT
// mapped address, so a non-global value is malformed by specification.
// The same reasoning as RFC 6052 § 3.1 for NAT64.

// Extraction-only: a Teredo address cannot be constructed from an IPv4
// address alone (server, flags, and port are not derivable), so `pack()`
// always throws.
throw new StrategyException\PackingException($binary, $this);
// It is down to the user of this library to know that you should not
// use this embedding strategy to pack. At all.
}
}
23 changes: 22 additions & 1 deletion src/Version/IPv6.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,12 +143,32 @@ 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()
&& !$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
Expand Down
24 changes: 19 additions & 5 deletions tests/DataProvider/IPv6.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,27 @@ 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,
// 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,
// 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,
Expand Down
Loading