Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.57% covered (warning)
88.57%
62 / 70
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
IpFilterMiddleware
88.57% covered (warning)
88.57%
62 / 70
60.00% covered (warning)
60.00%
3 / 5
30.26
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
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
6.05
 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\Utils\ClientIpHelper;
8use BO\Zmscitizenapi\Utils\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                $error = ErrorMessages::get(self::ERROR_BLACKLISTED);
48                $response = \App::$slim->getResponseFactory()->createResponse();
49                $response = $response->withStatus($error['statusCode'])
50                                ->withHeader('Content-Type', 'application/json');
51            // Write JSON response
52                            $responseBody = json_encode([
53                                'errors' => [$error]
54                            ]);
55                $response->getBody()->write($responseBody);
56                return $response;
57            }
58
59            return $handler->handle($request);
60        } catch (\Throwable $e) {
61            $this->logger->logError($e, $request);
62            throw $e;
63        }
64    }
65
66    private function parseIpList(?string $ipList): array
67    {
68        if (empty($ipList)) {
69            return [];
70        }
71
72        $list = array_map('trim', explode(',', $ipList));
73        return array_filter($list, function ($entry) {
74
75            if (strpos($entry, '/') !== false) {
76                list($ip, $bits) = explode('/', $entry);
77                return filter_var($ip, FILTER_VALIDATE_IP) &&
78                       is_numeric($bits) &&
79                       (int)$bits >= 0 &&
80                       (int)$bits <= (strpos($ip, ':') !== false ? self::IPV6_BITS : self::IPV4_BITS);
81            }
82            return filter_var($entry, FILTER_VALIDATE_IP);
83        });
84    }
85
86    private function isIpInList(string $ip, array $list): bool
87    {
88        if (empty($list)) {
89            return false;
90        }
91
92        foreach ($list as $range) {
93            if ($this->isIpInRange($ip, $range)) {
94                return true;
95            }
96        }
97
98        return false;
99    }
100
101    private function isIpInRange(string $ip, string $range): bool
102    {
103        $flags = FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6;
104        if (!filter_var($ip, FILTER_VALIDATE_IP, $flags)) {
105            return false;
106        }
107
108        if (strpos($range, '/') !== false) {
109            list($subnet, $bits) = explode('/', $range);
110            if (!filter_var($subnet, FILTER_VALIDATE_IP, $flags)) {
111                return false;
112            }
113
114            $ipBin = @inet_pton($ip);
115            $subnetBin = @inet_pton($subnet);
116
117            if (
118                $ipBin === false || $subnetBin === false ||
119                strlen($ipBin) !== strlen($subnetBin)
120            ) {
121                return false;
122            }
123
124            $bits = (int)$bits;
125            $maxBits = strlen($ipBin) === 4 ? self::IPV4_BITS : self::IPV6_BITS;
126            if ($bits < 0 || $bits > $maxBits) {
127                return false;
128            }
129
130            $bytes = strlen($ipBin);
131            $mask = str_repeat("\xFF", (int)($bits / 8));
132            if ($bits % 8) {
133                $mask .= chr(0xFF << (8 - ($bits % 8)));
134            }
135
136            $mask = str_pad($mask, $bytes, "\x00");
137            return ($ipBin & $mask) === ($subnetBin & $mask);
138        }
139
140        return $ip === $range;
141    }
142}