First version of the library, with unit tests.

+ Add a parser of Forwarded header.
+ Add classes for the Forwarded header in itself, and its components like a single Forward and its ForwardNodes.
+ Unit tests of some of the usable methods.
This commit is contained in:
Madeorsk 2023-05-26 02:25:15 +02:00
commit 6810b25f58
11 changed files with 2426 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Composer
/vendor/
# IDEA
*.iml

31
composer.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "madeorsk/forwarded",
"description": "Forwarded header (RFC 7239) parsing library for PHP.",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Madeorsk",
"email": "madeorsk@protonmail.com"
}
],
"scripts": {
"test": "phpunit tests/"
},
"autoload": {
"psr-4": {
"Madeorsk\\Forwarded\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Madeorsk\\Forwarded\\Tests\\": "tests/"
}
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.1"
}
}

1628
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
<?php
namespace Madeorsk\Forwarded\Exceptions;
/**
* Exception thrown when trying to guess the type on an empty node name.
*/
class EmptyNodeNameException extends \Exception
{
public function __construct()
{
parent::__construct("Empty node name while guessing type of the ForwardNode.");
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Madeorsk\Forwarded\Exceptions;
/**
* Exception thrown when the forward node interface is not an IP address and we are trying to get an IP address.
*/
class NotIpAddressException extends \Exception
{
/**
* @param string $nodeName The node name of the forward node.
*/
public function __construct(public string $nodeName)
{
parent::__construct("This forward node with the node name \"$this->nodeName\" is not an IP address.");
}
}

139
src/Forward.php Normal file
View File

@ -0,0 +1,139 @@
<?php
namespace Madeorsk\Forwarded;
use Madeorsk\Forwarded\Exceptions\EmptyNodeNameException;
/**
* Class of an element in the forwarded elements list as defined in RFC7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-7.1
*/
class Forward
{
/**
* Identifies the user-agent facing interface of the proxy.
* @var ForwardNode|null
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.1
*/
protected ?ForwardNode $by = null;
/**
* Identifies the node making the request to the proxy.
* @var ForwardNode|null
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.2
*/
protected ?ForwardNode $for = null;
/**
* The host request header field as received by the proxy.
* @var string|null
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.3
*/
protected ?string $host = null;
/**
* Indicates what protocol was used to make the request.
* @var string|null
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.4
*/
protected ?string $protocol = null;
/**
* Create a new Forward.
* @param array<string, string> $assoc - The associative array of token-value pairs in the forward.
* @throws EmptyNodeNameException
*/
public function __construct(array $assoc = [])
{
// Load the given associative array.
$this->loadAssoc($assoc);
}
/**
* Load the forward data from its token-value pairs.
* @param array<string, string> $assoc - The associative array of token-value pairs in the forward.
* @return void
* @throws EmptyNodeNameException
*/
public function loadAssoc(array $assoc = []): void
{
// Read the interfaces, if they are specified.
if (!empty($assoc["by"]))
$this->by = new ForwardNode($assoc["by"]);
if (!empty($assoc["for"]))
$this->for = new ForwardNode($assoc["for"]);
// Store the host, if it is specified.
if (!empty($assoc["host"]))
$this->host = $assoc["host"];
// Store the protocol, if it is specified.
if (!empty($assoc["proto"]))
$this->protocol = $assoc["proto"];
}
/**
* The "by" parameter is used to disclose the interface where the
* request came in to the proxy server.
* This is primarily added by reverse proxies that wish to forward this
* information to the backend server. It can also be interesting in a
* multihomed environment to signal to backend servers from which the
* request came.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.1
* @return ForwardNode|null - The node that identifies the interface, if there is one.
*/
public function by(): ?ForwardNode
{
return $this->by;
}
/**
* The "for" parameter is used to disclose information about the client
* that initiated the request and subsequent proxies in a chain of
* proxies.
* In a chain of proxy servers where this is fully utilized, the first
* "for" parameter will disclose the client where the request was first
* made, followed by any subsequent proxy identifiers. The last proxy
* in the chain is not part of the list of "for" parameters. The last
* proxy's IP address, and optionally a port number, are, however,
* readily available as the remote IP address at the transport layer.
* It can, however, be more relevant to read information about the last
* proxy from preceding "Forwarded" header field's "by" parameter, if
* present.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.2
* @return ForwardNode|null - The node that identifies the interface, if there is one.
*/
public function for(): ?ForwardNode
{
return $this->for;
}
/**
* The "host" parameter is used to forward the original value of the
* "Host" header field. This can be used, for example, by the origin
* server if a reverse proxy is rewriting the "Host" header field to
* some internal host name.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.3
* @return string|null - The original host, if there is one.
*/
public function host(): ?string
{
return $this->host;
}
/**
* The "proto" parameter has the value of the used protocol type.
* For example, in an environment where a reverse proxy is also used as
* a crypto offloader, this allows the origin server to rewrite URLs in
* a document to match the type of connection as the user agent
* requested, even though all connections to the origin server are
* unencrypted HTTP.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-5.4
* @return string|null - The original protocol, if there is one.
*/
public function protocol(): ?string
{
return $this->protocol;
}
}

181
src/ForwardNode.php Normal file
View File

@ -0,0 +1,181 @@
<?php
namespace Madeorsk\Forwarded;
use Madeorsk\Forwarded\Exceptions\EmptyNodeNameException;
use Madeorsk\Forwarded\Exceptions\NotIpAddressException;
/**
* The class of an interface that emitted or received the request (by / for).
* It is defined in section 6 of RFC 7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6
*/
class ForwardNode
{
/**
* The raw node name.
* @var string
*/
protected string $nodeName;
/**
* The type of the node.
* @var ForwardNodeType
*/
protected ForwardNodeType $nodeType;
/**
* Create a new forward interface class from a raw node name.
* @param string $nodeName - The raw node name.
* @throws EmptyNodeNameException
*/
public function __construct(string $nodeName)
{
$this->setNodeName($nodeName);
}
/**
* Set the node name.
* @param string $nodeName - The new node name.
* @return void
* @throws EmptyNodeNameException
*/
public function setNodeName(string $nodeName): void
{
$this->nodeName = $nodeName;
// After setting the node name, we should update the current node type.
$this->guessType();
}
/**
* Guess the node type based on the current node name.
* @return ForwardNodeType - The updated node type.
* @throws EmptyNodeNameException
*/
protected function guessType(): ForwardNodeType
{
if (empty($this->nodeName)) throw new EmptyNodeNameException();
if ($this->nodeName[0] == "_")
// If the node name starts with '_', it is an obfuscated identifier: https://datatracker.ietf.org/doc/html/rfc7239#section-6.3.
$this->nodeType = ForwardNodeType::IDENTIFIER;
elseif ($this->nodeName == "unknown")
// If the node name is "unknown", it is a special node from an unknown interface: https://datatracker.ietf.org/doc/html/rfc7239#section-6.2.
$this->nodeType = ForwardNodeType::UNKNOWN;
elseif ($this->nodeName[0] == "[")
// If the node starts with '[', it is an IPV6: https://datatracker.ietf.org/doc/html/rfc7239#section-6.1.
$this->nodeType = ForwardNodeType::IPV6;
else
// If nothing was true, it can only be an IPV4.
$this->nodeType = ForwardNodeType::IPV4;
return $this->nodeType; // Returning the updated node type.
}
/**
* Determine if the current node interface is an IP address.
* @return bool - True if it is an IP address, false otherwise.
*/
public function isIP(): bool
{
return in_array($this->nodeType, [ForwardNodeType::IPV4, ForwardNodeType::IPV6]);
}
/**
* Determine if the current node interface is an IPV4 address.
* @return bool - True if it is an IPV4 address, false otherwise.
*/
public function isV4(): bool
{
return $this->nodeType == ForwardNodeType::IPV4;
}
/**
* Determine if the current node interface is an IPV6 address.
* @return bool - True if it is an IPV6 address, false otherwise.
*/
public function isV6(): bool
{
return $this->nodeType == ForwardNodeType::IPV6;
}
/**
* Get the IP address from the node name.
* @return string - The IP address.
* @throws NotIpAddressException
*/
public function getIp(): string
{
if ($this->isV4())
return $this->getIpv4();
if ($this->isV6())
return $this->getIpv6();
throw new NotIpAddressException($this->nodeName);
}
/**
* Get the IPV4 address from the node name.
* @return string - The IPV4 address.
*/
public function getIpv4(): string
{
// We try to find the port separator character.
$portSeparator = strrpos($this->nodeName, ":");
return $portSeparator !== false
// If there is a port separator character, we cut the string to keep only the IP address.
? substr($this->nodeName, 0, $portSeparator)
// If there is no port separator character, we can return the whole node name that should contain only the IP address.
: $this->nodeName;
}
/**
* Get the IPV6 address from the node name.
* @return string - The IPV6 address.
*/
public function getIpv6(): string
{
return substr($this->nodeName, 1,
// Finding the end of the IPV6 address. It is always enclosed in square brackets, according RFC 7239: https://datatracker.ietf.org/doc/html/rfc7239#section-6.1.
strrpos($this->nodeName, "]") - 1);
}
/**
* Get the used port from the node name.
* @return int|null - The used port, if there is one specified.
*/
public function getPort(): ?int
{
// We try to find the port separator character.
$portSeparator = strrpos($this->nodeName, ":",
// In an IPV6 address, the port separator can only be found after the end of the IP address (always enclosed in square brackets, according RFC 7239: https://datatracker.ietf.org/doc/html/rfc7239#section-6.1).
($ipv6EndPos = strrpos($this->nodeName, "]")) !== false ? $ipv6EndPos : 0);
// If we found a port separator, there is one and we can return it.
return $portSeparator !== false ? intval(substr($this->nodeName, $portSeparator + 1)) : null;
}
/**
* Determine if the current node interface is unknown.
* @return bool - True if it is unknown, false otherwise.
*/
public function isUnknown(): bool
{
return $this->nodeType == ForwardNodeType::UNKNOWN;
}
/**
* Determine if the current node interface is an obfuscated identifier.
* @return bool - True if it is an obfuscated identifier, false otherwise.
*/
public function isIdentifier(): bool
{
return $this->nodeType == ForwardNodeType::IDENTIFIER;
}
/**
* Get the current obfuscated identifier without the leading '_'.
* @return string - The identifier, without the leading '_'.
*/
public function getIdentifier(): string
{
return substr($this->nodeName, 1);
}
}

31
src/ForwardNodeType.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace Madeorsk\Forwarded;
/**
* An enumeration of the possible types of nodes as defined in section 6 of RFC7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6
*/
enum ForwardNodeType: string
{
/**
* In the case of a node that is an IPV4, as defined in section 6.1.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6.1
*/
case IPV4 = "ipv4";
/**
* In the case of a node that is an IPV6, as defined in section 6.1.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6.1
*/
case IPV6 = "ipv6";
/**
* In the case of a node that is "unknown", as defined in section 6.2.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6.2
*/
case UNKNOWN = "unknown";
/**
* In the case of a node that is an obfuscated identifier, as defined in section 6.3.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-6.3
*/
case IDENTIFIER = "identifier";
}

71
src/Forwarded.php Normal file
View File

@ -0,0 +1,71 @@
<?php
namespace Madeorsk\Forwarded;
use Madeorsk\Forwarded\Exceptions\EmptyNodeNameException;
/**
* The parsed Forwarded header.
*/
class Forwarded
{
/**
* @var Forward[]
*/
protected array $forwards;
/**
* Create a new Forwarded parsed header.
* @param array<(array<string, string>|Forward)> $forwards - An array of forwards.
* @throws EmptyNodeNameException
*/
public function __construct(array $forwards = [])
{
$this->loadForwards($forwards);
}
/**
* Load the given forwards array.
* If the forwards array is an array of associative arrays, converting them as Forward objects.
* @param array<(array<string, string>|Forward)> $forwards - An array of forwards.
* @return void
* @throws EmptyNodeNameException
*/
public function loadForwards(array $forwards): void
{
if (!empty($forwards))
{ // If some forwards have been given, we read them and save them.
$forwards = array_values($forwards); // Getting a simple forwards array with no index.
if ($forwards[0] instanceof Forward)
// If we have an array of Forward objects, we can simply save it.
$this->forwards = $forwards;
else
// If we have an array of associative arrays for each forward,
// we convert each forward associative array to a Forward object before to save the array.
$this->forwards = array_map(function (array $assoc) {
// Converting the current associative array as a Forward object.
return new Forward($assoc);
}, $forwards);
}
// If there are no forwards, reset to an empty array.
else $this->forwards = [];
}
/**
* Get the first forward, if there is one.
* @return Forward|null - If NULL, there are no forward.
*/
public function first(): ?Forward
{
return !empty($this->forwards) ? $this->forwards[0] : null;
}
/**
* Get the forwards list.
* @return Forward[] - The forwards list.
*/
public function getForwards(): array
{
return $this->forwards;
}
}

223
src/Parser.php Normal file
View File

@ -0,0 +1,223 @@
<?php
namespace Madeorsk\Forwarded;
use Madeorsk\Forwarded\Exceptions\EmptyNodeNameException;
/**
* The Forwarded header parser, following the section 4 of RFC 7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-4
*/
class Parser
{
/**
* Parse the HTTP header found in `$_SERVER["HTTP_FORWARDED"]`.
* @return Forwarded - The parsed Forwarded header.
* @throws EmptyNodeNameException
*/
public function parseHttpHeader(): Forwarded
{
return $this->parse($_SERVER["HTTP_FORWARDED"]);
}
/**
* The currently reading forwards list.
* @var array
*/
protected array $forwards = [];
/**
* An associative array (token => value) of the currently reading forward.
* @var array<string, string>
*/
protected array $pairs = [];
/**
* The currently reading token.
* @var string
*/
protected string $token = "";
/**
* The currently reading value.
* @var string
*/
protected string $value = "";
/**
* The currently reading quoted string.
* @var string
*/
protected string $quotedString = "";
/**
* The name of the currently reading value, which determine the variable to alter and the state function to use.
* @var string
*/
protected string $currentState = self::TOKEN_STATE;
/**
* Parse the header content in an array of associative arrays with token => value.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-4 for token / value meaning.
* @param string $headerContent - The header content string.
* @return array<array<string, string>> - The parsed array of forwards [token => value] arrays.
*/
public function parseAssoc(string $headerContent): array
{
// Full reinitialization.
$this->forwards = [];
$this->pairs = [];
$this->reinitPairParsing();
foreach (mb_str_split($headerContent) as $char)
{ // For each character, we first execute the state function.
// The state function can change the state variable to the next state, so we store the current state in a local variable.
$currentState = $this->currentState;
if ($this->{"state".ucfirst($currentState)}($char))
// If the state function returned true, then we add the current char to the current state.
$this->{$currentState} .= $char;
}
if (!empty($this->token))
// If the token is not empty, we save the current pair.
$this->savePair();
if (!empty($this->pairs))
// If there are pairs, we save the current forward.
$this->saveForward();
return $this->forwards; // The parsing is finished, we can return the parsed forwards.
}
/**
* Parse the header content in a Forwarded header object.
* @param string $headerContent - The header content string.
* @return Forwarded - The parsed Forwarded header object.
* @throws EmptyNodeNameException
*/
public function parse(string $headerContent): Forwarded
{
return new Forwarded($this->parseAssoc($headerContent));
}
/**
* Reinitialize the parsing of a pair.
* @return void
*/
protected function reinitPairParsing(): void
{
$this->token = "";
$this->value = "";
$this->quotedString = "";
$this->currentState = self::TOKEN_STATE;
}
/**
* Save the currently parsing pair and reinitialize parsing to read another one.
* @return void
*/
protected function savePair(): void
{
// There should be at least one filled value between `value` and `quotedString`.
$this->pairs[trim($this->token)] = trim($this->value.$this->quotedString);
$this->reinitPairParsing();
}
/**
* Save the currently parsing forward and reinitialize parsing to read another one.
* @return void
*/
protected function saveForward(): void
{
if (!empty($this->token))
// If the token is not empty, we save the current pair.
$this->savePair();
$this->forwards[] = $this->pairs;
$this->pairs = [];
$this->reinitPairParsing();
}
/*
*
* =*= STATES CONSTANTS AND FUNCTIONS =*=
*
*/
const TOKEN_STATE = "token";
const VALUE_STATE = "value";
const QUOTED_STRING_STATE = "quotedString";
const QUOTED_STRING_ESCAPING_STATE = "quotedStringEscaping";
/**
* The state function of token parsing.
* @param string $char - The currently reading character.
* @return bool - True if the current character should be added to the token, false otherwise.
*/
protected function stateToken(string $char): bool
{
switch ($char)
{
case "=":
$this->currentState = self::VALUE_STATE;
return false;
default:
return true;
}
}
/**
* The state function of value parsing.
* @param string $char - The currently reading character.
* @return bool - True if the current character should be added to the value, false otherwise.
*/
protected function stateValue(string $char): bool
{
if (empty($this->value))
{ // If there is an empty value, we check if this value can be a quoted string.
if ($char == "\"")
{ // The value starts with a quote, it is a quoted string.
$this->currentState = self::QUOTED_STRING_STATE;
return false;
}
}
switch ($char)
{
case ";":
$this->savePair();
return false;
case ",":
$this->saveForward();
return false;
default:
return true;
}
}
/**
* The state function of quoted string parsing.
* @param string $char - The currently reading character.
* @return bool - True if the current character should be added to the value, false otherwise.
*/
protected function stateQuotedString(string $char): bool
{
switch ($char)
{
case "\"":
$this->currentState = self::VALUE_STATE;
return false;
case "\\":
$this->currentState = self::QUOTED_STRING_ESCAPING_STATE;
return false;
default:
return true;
}
}
/**
* The state function of "quoted string while escaping" parsing.
* @param string $char - The currently reading character.
* @return bool - True if the current character should be added to the value, false otherwise.
*/
protected function stateQuotedStringEscaping(string $char): bool
{
$this->currentState = self::QUOTED_STRING_STATE;
return true;
}
}

86
tests/ParseTest.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace Madeorsk\Forwarded\Tests;
use Madeorsk\Forwarded\Parser;
use PHPUnit\Framework\TestCase;
class ParseTest extends TestCase
{
/**
* The first example of Section 7.5 of RFC 7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-7.5
* @return void
*/
public function testSimpleForward(): void
{
$forwarded = (new Parser())->parse("for=192.0.2.43");
$this->assertSame(1, count($forwarded->getForwards()));
$this->assertSame("192.0.2.43", $forwarded->first()->for()->getIp());
$this->assertNull($forwarded->first()->by());
$this->assertNull($forwarded->first()->host());
$this->assertNull($forwarded->first()->protocol());
}
/**
* The example of Section 7.1 of RFC 7239.
* @see https://datatracker.ietf.org/doc/html/rfc7239#section-7.1
* @return void
*/
public function testExample71(): void
{
$forwarded = (new Parser())->parse("for=192.0.2.43,for=\"[2001:db8:cafe::17]\",for=unknown");
$this->assertSame(3, count($forwarded->getForwards()));
$this->assertSame("192.0.2.43", $forwarded->first()->for()->getIp());
$this->assertSame("2001:db8:cafe::17", $forwarded->getForwards()[1]->for()->getIp());
$this->assertTrue($forwarded->getForwards()[2]->for()->isUnknown());
}
public function testEverything(): void
{
$forwarded = (new Parser())->parse("for=192.0.2.43:55423;proto=http;host=test.dev;by=unknown,for=_something; by=unknown, for=\"[2001:db8:cafe::17]:22\";host=another.test;by=172.55.10.10,for=unknown");
// Testing that we read enough forwards.
$this->assertSame(4, count($forwarded->getForwards()));
// Testing the first forward.
$forward = $forwarded->getForwards()[0];
$this->assertTrue($forward->for()->isIP());
$this->assertTrue($forward->for()->isV4());
$this->assertSame("192.0.2.43", $forward->for()->getIp());
$this->assertSame(55423, $forward->for()->getPort());
$this->assertTrue($forward->by()->isUnknown());
$this->assertSame("test.dev", $forward->host());
$this->assertSame("http", $forward->protocol());
// Testing the second forward.
$forward = $forwarded->getForwards()[1];
$this->assertTrue($forward->for()->isIdentifier());
$this->assertSame("something", $forward->for()->getIdentifier());
$this->assertTrue($forward->by()->isUnknown());
$this->assertNull($forward->host());
$this->assertNull($forward->protocol());
// Testing the third forward.
$forward = $forwarded->getForwards()[2];
$this->assertTrue($forward->for()->isIP());
$this->assertTrue($forward->for()->isV6());
$this->assertSame("2001:db8:cafe::17", $forward->for()->getIp());
$this->assertSame(22, $forward->for()->getPort());
$this->assertTrue($forward->by()->isIP());
$this->assertTrue($forward->by()->isV4());
$this->assertSame("172.55.10.10", $forward->by()->getIp());
$this->assertNull($forward->by()->getPort());
$this->assertSame("another.test", $forward->host());
$this->assertNull($forward->protocol());
// Testing the fourth forward.
$forward = $forwarded->getForwards()[3];
$this->assertTrue($forward->for()->isUnknown());
$this->assertNull($forward->by());
$this->assertNull($forward->host());
$this->assertNull($forward->protocol());
}
}