netglue/RealIP-Helpers

View on GitHub
src/Helper/ClientIP.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?php

declare(strict_types=1);

namespace NetglueRealIP\Helper;

use function array_diff;
use function array_filter;
use function array_map;
use function count;
use function end;
use function explode;
use function filter_var;
use function in_array;
use function reset;
use function stripos;
use function strpos;
use function strtolower;
use function substr;
use function trim;

use const FILTER_FLAG_IPV4;
use const FILTER_FLAG_IPV6;
use const FILTER_VALIDATE_IP;

abstract class ClientIP
{
    /** @var bool Whether to evaluate proxy headers or not */
    private $proxyMode;

    /** @var string|null A header that we’re trusting to contain the real client IP */
    private $trustedHeader;

    /** @var string[] An array of trusted proxy IP addresses */
    private $trustedProxies = [];

    /** @var bool Whether the Remote Address is guaranteed to be a trusted proxy */
    private $remoteAddressIsTrustedProxy;

    /** @var string[] */
    private $proxyHeadersToInspect = [
        'Forwarded',
        'X-Forwarded-For',
        'X-Forwarded',
        'X-Cluster-Client-Ip',
        'Client-Ip',
    ];

    /**
     * @param string[] $trustedProxies
     * @param string[] $proxyHeadersToInspect
     */
    public function __construct(
        bool $proxyMode = false,
        ?string $trustedHeader = null,
        bool $remoteAddressIsTrustedProxy = false,
        array $trustedProxies = [],
        array $proxyHeadersToInspect = []
    ) {
        if (! empty($trustedProxies)) {
            $this->trustedProxies = $trustedProxies;
        }

        if (! empty($proxyHeadersToInspect)) {
            $this->proxyHeadersToInspect = $proxyHeadersToInspect;
        }

        $this->proxyMode = $proxyMode;
        $this->trustedHeader = $trustedHeader;
        $this->remoteAddressIsTrustedProxy = $remoteAddressIsTrustedProxy;
    }

    public function getIpAddress(): ?string
    {
        // The trusted header trumps everything:
        $ip = $this->getTrustedHeader();
        if ($ip) {
            return $ip;
        }

        // Just return REMOTE_ADDR if proxies are not to be checked
        $remote = $this->getRemoteAddress();
        if (! $this->proxyMode) {
            return $remote;
        }

        $remoteAddr = $this->getRemoteAddress();
        if ($this->remoteAddressIsTrustedProxy && ! empty($remoteAddr)) {
            $this->trustedProxies[] = $remoteAddr;
        }

        // If REMOTE_ADDR is not a trusted proxy, it's the client
        if (count($this->trustedProxies) && ! in_array($remote, $this->trustedProxies, true)) {
            return $remote;
        }

        return $this->searchProxyHeaders();
    }

    abstract public function getRemoteAddress(): ?string;

    abstract protected function getHeaderValue(string $headerName): ?string;

    private function searchProxyHeaders(): ?string
    {
        foreach ($this->proxyHeadersToInspect as $headerName) {
            $ips = $this->proxyHeaderToArray($headerName);
            if (! empty($ips) && count($this->trustedProxies)) {
                // The client IP is the left-most address, but when the all trusted proxies are removed,
                // the most trusted source of information would be the right most.
                $ips = array_diff($ips, $this->trustedProxies);

                return end($ips);
            }

            // There are no trusted proxies, so assume the left-most is the client
            if (! empty($ips)) {
                return reset($ips);
            }
        }

        return $this->getRemoteAddress();
    }

    private function getTrustedHeader(): ?string
    {
        if ($this->trustedHeader) {
            $value = $this->getHeaderValue($this->trustedHeader);
            if ($value) {
                return $this->filterIp($value);
            }
        }

        return null;
    }

    public function filterIp(string $ip): ?string
    {
        $ip = $this->removePort($ip);
        if (! $this->validateIp($ip)) {
            return null;
        }

        return $ip;
    }

    /** @return string[] */
    private function proxyHeaderToArray(string $headerName): array
    {
        $headerValue = $this->getHeaderValue($headerName);
        if (! $headerValue) {
            return [];
        }

        $items = array_map('trim', explode(',', $headerValue));

        if (strtolower($headerName) === 'forwarded') {
            foreach (explode(';', $headerValue) as $headerPart) {
                if (stripos($headerPart, 'for=') === 0) {
                    $items = explode(',', $headerPart);
                    $items = array_map(static function ($value): string {
                        // IPv6 is quoted: For="[2001:db8:cafe::17]:4711"
                        return trim(trim(substr($value, 4)), '"');
                    }, $items);
                    break;
                }
            }
        }

        $items = array_map([$this, 'filterIp'], $items);

        return array_filter($items);
    }

    private function removePort(string $ipAddress): string
    {
        if (strpos($ipAddress, ']:') !== false) {
            $parts = explode(']', $ipAddress);
            $parts[0] = trim($parts[0], '[');
            if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
                return $parts[0];
            }
        }

        $parts = explode(':', $ipAddress);
        if ((count($parts) === 2) && filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
            return $parts[0];
        }

        return $ipAddress;
    }

    private function validateIp(string $ip): bool
    {
        $flags = FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6;

        return ! (filter_var($ip, FILTER_VALIDATE_IP, $flags) === false);
    }
}