Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.73% covered (warning)
88.73%
63 / 71
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
IpFilterMiddleware
88.73% covered (warning)
88.73%
63 / 71
60.00% covered (warning)
60.00%
3 / 5
30.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 process
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
6.04
 parseIpList
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 isIpInList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isIpInRange
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
12.24
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Middleware;
6
7use BO\Zmscitizenapi\Helper\ClientIpHelper;
8use BO\Zmscitizenapi\Localization\ErrorMessages;
9use BO\Zmscitizenapi\Services\Core\LoggerService;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12use Psr\Http\Server\MiddlewareInterface;
13use Psr\Http\Server\RequestHandlerInterface;
14
15class IpFilterMiddleware implements MiddlewareInterface
16{
17    private const ERROR_BLACKLISTED = 'ipBlacklisted';
18    private const IPV4_BITS = 32;
19    private const IPV6_BITS = 128;
20    private string $blacklist;
21    private LoggerService $logger;
22    public function __construct(LoggerService $logger)
23    {
24        $this->logger = $logger;
25        $this->blacklist = \App::getIpBlacklist();
26    }
27
28    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
29    {
30        try {
31            $ip = ClientIpHelper::getClientIp();
32            $uri = (string)$request->getUri();
33            if ($ip === null || !filter_var($ip, FILTER_VALIDATE_IP)) {
34                $this->logger->logInfo('Invalid IP address detected', [
35                    'ip' => $ip,
36                    'uri' => $uri
37                ]);
38                return $handler->handle($request);
39            }
40
41            $blacklist = $this->parseIpList($this->blacklist ?: null);
42            if ($this->isIpInList($ip, $blacklist)) {
43                $this->logger->logInfo('Access denied - IP blacklisted', [
44                    'ip' => $ip,
45                    'uri' => $uri
46                ]);
47                $language = $request->getAttribute('language');
48                $error = ErrorMessages::get(self::ERROR_BLACKLISTED, $language);
49                $response = \App::$slim->getResponseFactory()->createResponse();
50                $response = $response->withStatus($error['statusCode'])
51                                ->withHeader('Content-Type', 'application/json');
52            // Write JSON response
53                            $responseBody = json_encode([
54                                'errors' => [$error]
55                            ]);
56                $response->getBody()->write($responseBody);
57                return $response;
58            }
59
60            return $handler->handle($request);
61        } catch (\Throwable $e) {
62            $this->logger->logError($e, $request);
63            throw $e;
64        }
65    }
66
67    private function parseIpList(?string $ipList): array
68    {
69        if (empty($ipList)) {
70            return [];
71        }
72
73        $list = array_map('trim', explode(',', $ipList));
74        return array_filter($list, function ($entry) {
75
76            if (strpos($entry, '/') !== false) {
77                list($ip, $bits) = explode('/', $entry);
78                return filter_var($ip, FILTER_VALIDATE_IP) &&
79                       is_numeric($bits) &&
80                       (int)$bits >= 0 &&
81                       (int)$bits <= (strpos($ip, ':') !== false ? self::IPV6_BITS : self::IPV4_BITS);
82            }
83            return filter_var($entry, FILTER_VALIDATE_IP);
84        });
85    }
86
87    private function isIpInList(string $ip, array $list): bool
88    {
89        if (empty($list)) {
90            return false;
91        }
92
93        foreach ($list as $range) {
94            if ($this->isIpInRange($ip, $range)) {
95                return true;
96            }
97        }
98
99        return false;
100    }
101
102    private function isIpInRange(string $ip, string $range): bool
103    {
104        $flags = FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6;
105        if (!filter_var($ip, FILTER_VALIDATE_IP, $flags)) {
106            return false;
107        }
108
109        if (strpos($range, '/') !== false) {
110            list($subnet, $bits) = explode('/', $range);
111            if (!filter_var($subnet, FILTER_VALIDATE_IP, $flags)) {
112                return false;
113            }
114
115            $ipBin = @inet_pton($ip);
116            $subnetBin = @inet_pton($subnet);
117
118            if (
119                $ipBin === false || $subnetBin === false ||
120                strlen($ipBin) !== strlen($subnetBin)
121            ) {
122                return false;
123            }
124
125            $bits = (int)$bits;
126            $maxBits = strlen($ipBin) === 4 ? self::IPV4_BITS : self::IPV6_BITS;
127            if ($bits < 0 || $bits > $maxBits) {
128                return false;
129            }
130
131            $bytes = strlen($ipBin);
132            $mask = str_repeat("\xFF", (int)($bits / 8));
133            if ($bits % 8) {
134                $mask .= chr(0xFF << (8 - ($bits % 8)));
135            }
136
137            $mask = str_pad($mask, $bytes, "\x00");
138            return ($ipBin & $mask) === ($subnetBin & $mask);
139        }
140
141        return $ip === $range;
142    }
143}