Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.86% covered (warning)
70.86%
107 / 151
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
IpSecurityService
70.86% covered (warning)
70.86%
107 / 151
40.00% covered (danger)
40.00%
4 / 10
111.85
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
 getClientIp
41.67% covered (danger)
41.67%
10 / 24
0.00% covered (danger)
0.00%
0 / 1
74.37
 parseIpHeader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isValidIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternalIp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 isIpBlocked
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 blockIp
83.87% covered (warning)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
4.07
 unblockIp
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 isSuspiciousRequest
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 trackSuspiciousActivity
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
7.12
1<?php
2declare(strict_types=1);
3
4namespace App\Service;
5
6use App\Utility\SettingsManager;
7use Cake\Cache\Cache;
8use Cake\Core\Configure;
9use Cake\Http\ServerRequest;
10use Cake\I18n\DateTime;
11use Cake\Log\Log;
12use Cake\ORM\Table;
13use Cake\ORM\TableRegistry;
14
15/**
16 * IpSecurityService handles IP-based security measures including blocking and suspicious activity detection.
17 *
18 * This service provides functionality to:
19 * - Detect client IP addresses with proxy support
20 * - Check if IP addresses are blocked
21 * - Block IP addresses with optional expiration
22 * - Detect suspicious requests based on URL patterns
23 * - Track and respond to suspicious activity
24 */
25class IpSecurityService
26{
27    /**
28     * Regular expression patterns used to identify suspicious behavior in requests.
29     * Loaded from configuration.
30     *
31     * @var array<string>
32     */
33    private array $suspiciousPatterns = [];
34
35    /**
36     * @var \Cake\ORM\Table
37     */
38    private Table $blockedIpsTable;
39
40    /**
41     * @var array<string> List of proxy headers to check in order of preference
42     */
43    private array $proxyHeaders = [
44        'HTTP_CF_CONNECTING_IP', // Cloudflare
45        'HTTP_X_FORWARDED_FOR', // Standard proxy header
46        'HTTP_X_REAL_IP', // Nginx
47        'HTTP_CLIENT_IP', // Some proxies
48    ];
49
50    /**
51     * Constructor
52     *
53     * @param \Cake\ORM\Table|null $blockedIpsTable Table instance for BlockedIps
54     */
55    public function __construct(?Table $blockedIpsTable = null)
56    {
57        $this->blockedIpsTable = $blockedIpsTable ?? TableRegistry::getTableLocator()->get('BlockedIps');
58
59        // Load suspicious patterns from configuration
60        $this->suspiciousPatterns = Configure::read('IpSecurity.suspiciousPatterns', []);
61    }
62
63    /**
64     * Get client IP address with proxy support
65     *
66     * @param \Cake\Http\ServerRequest $request The request
67     * @return string|null The client IP address or null if cannot be determined
68     */
69    public function getClientIp(ServerRequest $request): ?string
70    {
71        // Check if IP was already determined by another middleware
72        $attributeIp = $request->getAttribute('clientIp');
73        if ($attributeIp && $this->isValidIp($attributeIp)) {
74            return $attributeIp;
75        }
76
77        // Try CakePHP's built-in method
78        $ip = $request->clientIp();
79        if ($ip && $this->isValidIp($ip)) {
80            return $ip;
81        }
82
83        $serverParams = $request->getServerParams();
84
85        // If trust proxy is enabled, check proxy headers
86        if (SettingsManager::read('Security.trustProxy', false)) {
87            $trustedProxiesConfig = SettingsManager::read('Security.trustedProxies', '');
88            $trustedProxies = array_filter(
89                array_map('trim', explode("\n", $trustedProxiesConfig)),
90            );
91
92            // Check if request is from a trusted proxy
93            $remoteAddr = $serverParams['REMOTE_ADDR'] ?? null;
94
95            // If trusted proxies are configured, verify the request is from one
96            if (!empty($trustedProxies) && $remoteAddr) {
97                if (!in_array($remoteAddr, $trustedProxies)) {
98                    // Request is not from a trusted proxy, use REMOTE_ADDR
99                    return $this->isValidIp($remoteAddr) ? $remoteAddr : null;
100                }
101            }
102
103            // Check proxy headers in order of preference
104            foreach ($this->proxyHeaders as $header) {
105                if (!empty($serverParams[$header])) {
106                    $ips = $this->parseIpHeader($serverParams[$header]);
107                    foreach ($ips as $ip) {
108                        if ($this->isValidIp($ip) && !$this->isInternalIp($ip)) {
109                            return $ip;
110                        }
111                    }
112                }
113            }
114        }
115
116        // Fall back to REMOTE_ADDR
117        $remoteAddr = $serverParams['REMOTE_ADDR'] ?? null;
118
119        return $remoteAddr && $this->isValidIp($remoteAddr) ? $remoteAddr : null;
120    }
121
122    /**
123     * Parse IP header which may contain multiple IPs
124     *
125     * @param string $header The header value to parse
126     * @return array<string> Array of IP addresses
127     */
128    private function parseIpHeader(string $header): array
129    {
130        // Handle comma-separated list of IPs
131        $ips = array_map('trim', explode(',', $header));
132
133        // Return IPs in order (leftmost is usually the client)
134        return $ips;
135    }
136
137    /**
138     * Validate IP address
139     *
140     * @param string $ip The IP to validate
141     * @return bool True if valid IP
142     */
143    private function isValidIp(string $ip): bool
144    {
145        return filter_var($ip, FILTER_VALIDATE_IP) !== false;
146    }
147
148    /**
149     * Check if IP is internal/private
150     *
151     * @param string $ip The IP to check
152     * @return bool True if internal/private IP
153     */
154    private function isInternalIp(string $ip): bool
155    {
156        return !filter_var(
157            $ip,
158            FILTER_VALIDATE_IP,
159            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
160        );
161    }
162
163    /**
164     * Checks if an IP address is currently blocked.
165     *
166     * @param string $ip The IP address to check
167     * @return bool True if the IP is blocked, false otherwise
168     */
169    public function isIpBlocked(string $ip): bool
170    {
171        $cacheKey = 'blocked_ip_' . md5($ip);
172        $blockedStatus = Cache::read($cacheKey, 'ip_blocker');
173
174        if ($blockedStatus === null) {
175            $blockedIp = $this->blockedIpsTable->find()
176                ->where(['ip_address' => $ip])
177                ->where(function ($exp) {
178                    return $exp->or([
179                        'expires_at IS' => null,
180                        'expires_at >' => DateTime::now(),
181                    ]);
182                })
183                ->first();
184
185            $blockedStatus = $blockedIp !== null;
186
187            if ($blockedStatus && $blockedIp) {
188                // Cache blocked status using config's default duration
189                Cache::write($cacheKey, true, 'ip_blocker');
190            } else {
191                // IP is not blocked, cache this status
192                Cache::write($cacheKey, false, 'ip_blocker');
193            }
194        }
195
196        return (bool)$blockedStatus;
197    }
198
199    /**
200     * Blocks an IP address with an optional expiration time.
201     *
202     * @param string $ip The IP address to block
203     * @param string $reason The reason for blocking the IP
204     * @param \Cake\I18n\DateTime|null $expiresAt Optional expiration time for the block
205     * @return bool True if the block was successfully saved, false otherwise
206     */
207    public function blockIp(string $ip, string $reason, ?DateTime $expiresAt = null): bool
208    {
209        // Check for an active existing block to update
210        $existing = $this->blockedIpsTable->find()
211            ->where(['ip_address' => $ip])
212            ->where(function ($exp) {
213                return $exp->or([
214                    'expires_at IS' => null,
215                    'expires_at >' => DateTime::now(),
216                ]);
217            })
218            ->first();
219
220        if ($existing) {
221            // Update existing block
222            $entity = $this->blockedIpsTable->patchEntity($existing, [
223                'reason' => $reason,
224                'expires_at' => $expiresAt,
225            ]);
226        } else {
227            // Create new block
228            $entity = $this->blockedIpsTable->newEntity([
229                'ip_address' => $ip,
230                'reason' => $reason,
231                'expires_at' => $expiresAt,
232                'created' => DateTime::now(),
233            ]);
234        }
235
236        if ($this->blockedIpsTable->save($entity)) {
237            // Cache blocked status using config's default duration
238            $cacheKey = 'blocked_ip_' . md5($ip);
239            Cache::write($cacheKey, true, 'ip_blocker');
240
241            Log::warning(__('IP address blocked: {0} for {1}', [$ip, $reason]), [
242                'ip' => $ip,
243                'reason' => $reason,
244                'expires_at' => $expiresAt ? $expiresAt->format('Y-m-d H:i:s') : 'never',
245                'group_name' => 'security',
246            ]);
247
248            return true;
249        }
250
251        return false;
252    }
253
254    /**
255     * Unblocks an IP address by removing it from the blocked list.
256     *
257     * @param string $ip The IP address to unblock
258     * @return bool True if the IP was successfully unblocked
259     */
260    public function unblockIp(string $ip): bool
261    {
262        $blockedIp = $this->blockedIpsTable->find()
263            ->where(['ip_address' => $ip])
264            ->first();
265
266        if ($blockedIp) {
267            if ($this->blockedIpsTable->delete($blockedIp)) {
268                Cache::delete('blocked_ip_' . md5($ip), 'ip_blocker');
269                Log::info(__('IP address unblocked: {0}', [$ip]), [
270                    'ip' => $ip,
271                    'group_name' => 'security',
272                ]);
273
274                return true;
275            }
276
277            return false;
278        } else {
279            // IP was not in the blocked list
280            Cache::delete('blocked_ip_' . md5($ip), 'ip_blocker');
281
282            return true;
283        }
284    }
285
286    /**
287     * Checks if a request matches known suspicious patterns.
288     *
289     * @param \Cake\Http\ServerRequest $request The full ServerRequest object.
290     * @return bool True if the request matches suspicious patterns, false otherwise
291     */
292    public function isSuspiciousRequest(ServerRequest $request): bool
293    {
294        $uri = $request->getUri();
295        $fullUrl = $uri->getPath() . ($uri->getQuery() ? '?' . $uri->getQuery() : '');
296        $decodedUrl = urldecode($fullUrl);
297        $doubleDecodedUrl = urldecode($decodedUrl);
298
299        // Check all versions of the URL against patterns
300        foreach ($this->suspiciousPatterns as $pattern) {
301            if (
302                preg_match($pattern, $fullUrl) ||
303                preg_match($pattern, $decodedUrl) ||
304                preg_match($pattern, $doubleDecodedUrl)
305            ) {
306                // Get client IP using the same method
307                $clientIp = $this->getClientIp($request) ?: 'unknown';
308
309                // Log the detected suspicious activity
310                Log::warning(__('Suspicious request detected: {0}', [$fullUrl]), [
311                    'pattern' => $pattern,
312                    'raw_url' => $fullUrl,
313                    'decoded_url' => $decodedUrl,
314                    'double_decoded' => $doubleDecodedUrl,
315                    'group_name' => 'security',
316                    'client' => [
317                        'ip_address' => $clientIp,
318                        'user_agent' => $request->getHeaderLine('User-Agent') ?: 'unknown',
319                        'request_method' => $request->getMethod() ?: 'unknown',
320                        'referer' => $request->referer() ?: 'none',
321                        'host' => $request->host() ?: 'unknown',
322                    ],
323                ]);
324
325                return true;
326            }
327        }
328
329        return false;
330    }
331
332    /**
333     * Tracks suspicious activity for an IP address and implements progressive blocking.
334     *
335     * @param string $ip The IP address to track
336     * @param string $route The route that triggered the suspicious activity
337     * @param string $query The query string that triggered the suspicious activity
338     * @return void
339     */
340    public function trackSuspiciousActivity(string $ip, string $route, string $query): void
341    {
342        $key = 'suspicious_' . md5($ip);
343        $data = Cache::read($key, 'ip_blocker') ?: [
344            'count' => 0,
345            'first_seen' => time(),
346            'routes' => [],
347        ];
348
349        $data['count']++;
350        $data['routes'][] = [
351            'route' => $route,
352            'query' => $query,
353            'time' => time(),
354        ];
355
356        // Keep only the last 5 suspicious routes
357        $data['routes'] = array_slice($data['routes'], -5);
358
359        Cache::write($key, $data, 'ip_blocker');
360
361        // Block if multiple suspicious requests within time window
362        $suspiciousThreshold = (int)SettingsManager::read('Security.suspiciousRequestThreshold', 3);
363        $suspiciousWindow = (int)SettingsManager::read('Security.suspiciousWindowHours', 24) * 3600;
364
365        if ($data['count'] >= $suspiciousThreshold && (time() - $data['first_seen']) <= $suspiciousWindow) {
366            $reason = __('Multiple suspicious requests detected');
367            $blockHours = (int)SettingsManager::read('Security.suspiciousBlockHours', 24);
368            $expiresAt = DateTime::now()->modify("+{$blockHours} hours");
369
370            // Check for repeat offenders
371            $previousBlock = $this->blockedIpsTable->find()
372                ->where(['ip_address' => $ip])
373                ->orderByDesc('created')
374                ->first();
375
376            if ($previousBlock) {
377                if ($previousBlock->expires_at === null || $previousBlock->expires_at->isPast()) {
378                    // Double the block time for repeat offenders
379                    $blockHours *= 2;
380                    $expiresAt = DateTime::now()->modify("+{$blockHours} hours");
381                    $reason = __('Repeat offender: Multiple suspicious requests detected');
382                }
383            }
384
385            $this->blockIp($ip, $reason, $expiresAt);
386        }
387    }
388}