PL
apps/caramail-server / src / Net / ClientIp.php · commit 070afda
<?phpnamespace App\Net;/** * The visitor's public address, for the owner's role choice (CARAMAIL_OWNER_IPS). A WebSocket comes through Traefik * (and Cloudflare when the host is proxied): the headers are believed only when the socket's peer is a private * address (Traefik, on the Docker network), and Cloudflare's CF-Connecting-IP only when Traefik saw a Cloudflare * address: anyone can reach the origin directly and write those headers himself. */final class ClientIp{    /** https://www.cloudflare.com/ips/ */    private const CLOUDFLARE = ['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18',        '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15',        '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32',        '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32'];    /** "1.2.3.4:5678" or "[::1]:5678" -> "1.2.3.4" / "::1" */    public static function host(string $peer): string    {        if (preg_match('/^\[([^\]]+)\]:\d+$/', $peer, $m)) {            return $m[1];        }        return preg_match('/^([^:]+):\d+$/', $peer, $m) ? $m[1] : $peer;    }    /** @param array<string, string> $headers lower-case names */    public static function resolve(string $peer, array $headers): string    {        $ip = self::host($peer);        if (!self::isPrivate($ip)) {            return $ip;        }        // Traefik replaces X-Forwarded-For with the address it saw (it trusts no incoming X-Forwarded-* by default)        $forwarded = array_map('trim', explode(',', $headers['x-forwarded-for'] ?? ''));        $seen = (string)end($forwarded);        if ($seen === '' || filter_var($seen, FILTER_VALIDATE_IP) === false) {            $seen = trim($headers['x-real-ip'] ?? '');        }        if (filter_var($seen, FILTER_VALIDATE_IP) === false) {            return $ip;        }        $cf = trim($headers['cf-connecting-ip'] ?? '');        if ($cf !== '' && filter_var($cf, FILTER_VALIDATE_IP) !== false && self::inAny($seen, self::CLOUDFLARE)) {            return $cf;        }        return $seen;    }    public static function isPrivate(string $ip): bool    {        return filter_var($ip, FILTER_VALIDATE_IP) !== false            && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;    }    /** @param list<string> $ranges */    public static function inAny(string $ip, array $ranges): bool    {        $bin = @inet_pton($ip);        if ($bin === false) {            return false;        }        foreach ($ranges as $range) {            [$net, $bits] = array_pad(explode('/', $range, 2), 2, null);            $netBin = @inet_pton((string)$net);            if ($netBin === false || strlen($netBin) !== strlen($bin)) {                continue;            }            $bits = $bits === null ? strlen($bin) * 8 : (int)$bits;            $bytes = intdiv($bits, 8);            if (substr($bin, 0, $bytes) !== substr($netBin, 0, $bytes)) {                continue;            }            $rest = $bits % 8;            if ($rest === 0 || ((ord($bin[$bytes]) ^ ord($netBin[$bytes])) & (0xff << (8 - $rest)) & 0xff) === 0) {                return true;            }        }        return false;    }}