Passed
Push — 2.x ( 8d739d...192121 )
by Terry
02:09
created

FilterTrait::setFilter()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 2
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 4
rs 10
1
<?php
2
/*
3
 * This file is part of the Shieldon package.
4
 *
5
 * (c) Terry L. <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
declare(strict_types=1);
12
13
namespace Shieldon\Firewall\Kernel;
14
15
use Shieldon\Firewall\Kernel;
16
use function Shieldon\Firewall\get_request;
17
use function Shieldon\Firewall\get_session;
18
use function Shieldon\Firewall\unset_superglobal;
19
20
/*
21
 * This trait is used on Kernel only.
22
 */
23
trait FilterTrait
24
{
25
    /**
26
     * Enable or disable the filters.
27
     *
28
     * @var array
29
     */
30
    protected $filterStatus = [
31
        /**
32
         * Check how many pageviews an user made in a short period time.
33
         * For example, limit an user can only view 30 pages in 60 minutes.
34
         */
35
        'frequency' => true,
36
37
        /**
38
         * If an user checks any internal link on your website, the user's
39
         * browser will generate HTTP_REFERER information.
40
         * When a user view many pages without HTTP_REFERER information meaning
41
         * that the user MUST be a web crawler.
42
         */
43
        'referer' => false,
44
45
        /**
46
         * Most of web crawlers do not render JavaScript, they only get the 
47
         * content they want, so we can check whether the cookie can be created
48
         * by JavaScript or not.
49
         */
50
        'cookie' => false,
51
52
        /**
53
         * Every unique user should only has a unique session, but if a user
54
         * creates different sessions every connection... meaning that the 
55
         * user's browser doesn't support cookie.
56
         * It is almost impossible that modern browsers not support cookie,
57
         * therefore the user MUST be a web crawler.
58
         */
59
        'session' => false,
60
    ];
61
62
    /**
63
     * The status for Filters to reset.
64
     *
65
     * @var array
66
     */
67
    protected $filterResetStatus = [
68
        's' => false, // second.
69
        'm' => false, // minute.
70
        'h' => false, // hour.
71
        'd' => false, // day.
72
    ];
73
74
    /**
75
     * Set the filters.
76
     *
77
     * @param array $settings filter settings.
78
     *
79
     * @return void
80
     */
81
    public function setFilters(array $settings)
82
    {
83
        foreach (array_keys($this->filterStatus) as $k) {
84
            if (isset($settings[$k])) {
85
                $this->filterStatus[$k] = $settings[$k] ?? false;
86
            }
87
        }
88
    }
89
90
    /**
91
     * Set a filter.
92
     *
93
     * @param string $filterName The filter's name.
94
     * @param bool   $value      True for enabling the filter, overwise.
95
     *
96
     * @return void
97
     */
98
    public function setFilter(string $filterName, bool $value): void
99
    {
100
        if (isset($this->filterStatus[$filterName])) {
101
            $this->filterStatus[$filterName] = $value;
102
        }
103
    }
104
105
    /**
106
     * Disable filters.
107
     */
108
    public function disableFilters(): void
109
    {
110
        $this->setFilters([
111
            'session'   => false,
112
            'cookie'    => false,
113
            'referer'   => false,
114
            'frequency' => false,
115
        ]);
116
    }
117
118
    /*
119
    |--------------------------------------------------------------------------
120
    | Stage in Kernel
121
    |--------------------------------------------------------------------------
122
    | The below methods are used in "process" method in Kernel.
123
    */
124
125
    /**
126
     * Detect and analyze an user's behavior.
127
     *
128
     * @return int The response code.
129
     */
130
    protected function filter(): int
131
    {
132
        $now = time();
133
        $isFlagged = false;
134
135
        // Fetch an IP data from Shieldon log table.
136
        $ipDetail = $this->driver->get($this->ip, 'filter');
137
138
        $ipDetail = $this->driver->parseData($ipDetail, 'filter');
139
        $logData = $ipDetail;
140
141
        // Counting user pageviews.
142
        foreach (array_keys($this->filterResetStatus) as $unit) {
143
144
            // Each time unit will increase by 1.
145
            $logData['pageviews_' . $unit] = $ipDetail['pageviews_' . $unit] + 1;
146
            $logData['first_time_' . $unit] = $ipDetail['first_time_' . $unit];
147
        }
148
149
        $logData['first_time_flag'] = $ipDetail['first_time_flag'];
150
151
        if (!empty($ipDetail['ip'])) {
152
            $logData['ip'] = $this->ip;
153
            $logData['session'] = get_session()->get('id');
154
            $logData['hostname'] = $this->rdns;
155
            $logData['last_time'] = $now;
156
157
            // Start checking...
158
            foreach (array_keys($this->filterStatus) as $filter) {
159
160
                // For example: filterSession
161
                $method = 'filter' . ucfirst($filter);
162
163
                // For example: call $this->filterSession
164
                $filterReturnData = $this->{$method}($logData, $ipDetail, $isFlagged);
165
166
                // The log data will be updated by the filter.
167
                $logData = $filterReturnData['log_data'];
168
169
                // The flag will be passed to the next Filter.
170
                $isFlagged = $filterReturnData['is_flagged'];
171
172
                // If we find this session reached the filter limit, reject it.
173
                $isReject = $filterReturnData['is_reject'];
174
175
                if ($isReject) {
176
                    return kernel::RESPONSE_TEMPORARILY_DENY;
177
                }
178
            }
179
180
            // Is fagged as unusual beavior? Count the first time.
181
            if ($isFlagged) {
182
                $logData['first_time_flag'] = (!empty($logData['first_time_flag'])) ? $logData['first_time_flag'] : $now;
183
            }
184
185
            // Reset the flagged factor check.
186
            if (!empty($ipDetail['first_time_flag'])) {
187
                if ($now - $ipDetail['first_time_flag'] >= $this->properties['time_reset_limit']) {
188
                    $logData['flag_multi_session'] = 0;
189
                    $logData['flag_empty_referer'] = 0;
190
                    $logData['flag_js_cookie'] = 0;
191
                }
192
            }
193
194
            $this->driver->save($this->ip, $logData, 'filter');
195
196
        } else {
197
198
            // If $ipDetail[ip] is empty.
199
            // It means that the user is first time visiting our webiste.
200
            $this->InitializeFirstTimeFilter($logData);
201
        }
202
203
        return kernel::RESPONSE_ALLOW;
204
    }
205
206
    /*
207
    |--------------------------------------------------------------------------
208
    | The below methods are used only in "filter" method in current Trait.
209
    | See "Start checking..."
210
    |--------------------------------------------------------------------------
211
    */
212
213
    /**
214
     * When the user is first time visiting our webiste.
215
     * Initialize the log data.
216
     * 
217
     * @param array $logData The user's log data.
218
     *
219
     * @return void
220
     */
221
    protected function InitializeFirstTimeFilter($logData)
222
    {
223
        $now = time();
224
225
        $logData['ip']        = $this->ip;
226
        $logData['session']   = get_session()->get('id');
227
        $logData['hostname']  = $this->rdns;
228
        $logData['last_time'] = $now;
229
230
        foreach (array_keys($this->filterResetStatus) as $unit) {
231
            $logData['first_time_' . $unit] = $now;
232
        }
233
234
        $this->driver->save($this->ip, $logData, 'filter');
235
    }
236
237
    /**
238
     * Filter - Referer.
239
     *
240
     * @param array $logData   IP data from Shieldon log table.
241
     * @param array $ipData    The IP log data.
242
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
243
     *
244
     * @return array
245
     */
246
    protected function filterReferer(array $logData, array $ipDetail, bool $isFlagged): array
247
    {
248
        $isReject = false;
249
250
        if ($this->filterStatus['referer']) {
251
252
            if ($logData['last_time'] - $ipDetail['last_time'] > $this->properties['interval_check_referer']) {
253
254
                // Get values from data table. We will count it and save it back to data table.
255
                // If an user is already in your website, it is impossible no referer when he views other pages.
256
                $logData['flag_empty_referer'] = $ipDetail['flag_empty_referer'] ?? 0;
257
258
                if (empty(get_request()->getHeaderLine('referer'))) {
259
                    $logData['flag_empty_referer']++;
260
                    $isFlagged = true;
261
                }
262
263
                // Ban this IP if they reached the limit.
264
                if ($logData['flag_empty_referer'] > $this->properties['limit_unusual_behavior']['referer']) {
265
                    $this->action(
0 ignored issues
show
Bug introduced by
It seems like action() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

265
                    $this->/** @scrutinizer ignore-call */ 
266
                           action(
Loading history...
266
                        kernel::ACTION_TEMPORARILY_DENY,
267
                        kernel::REASON_EMPTY_REFERER
268
                    );
269
                    $isReject = true;
270
                }
271
            }
272
        }
273
274
        return [
275
            'is_flagged' => $isFlagged,
276
            'is_reject' => $isReject,
277
            'log_data' => $logData,
278
        ];
279
    }
280
281
    /**
282
     * Filter - Session
283
     *
284
     * @param array $logData   IP data from Shieldon log table.
285
     * @param array $ipData    The IP log data.
286
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
287
     *
288
     * @return array
289
     */
290
    protected function filterSession(array $logData, array $ipDetail, bool $isFlagged): array
291
    {
292
        $isReject = false;
293
294
        if ($this->filterStatus['session']) {
295
296
            if ($logData['last_time'] - $ipDetail['last_time'] > $this->properties['interval_check_session']) {
297
298
                // Get values from data table. We will count it and save it back to data table.
299
                $logData['flag_multi_session'] = $ipDetail['flag_multi_session'] ?? 0;
300
                
301
                if (get_session()->get('id') !== $ipDetail['session']) {
302
303
                    // Is is possible because of direct access by the same user many times.
304
                    // Or they don't have session cookie set.
305
                    $logData['flag_multi_session']++;
306
                    $isFlagged = true;
307
                }
308
309
                // Ban this IP if they reached the limit.
310
                if ($logData['flag_multi_session'] > $this->properties['limit_unusual_behavior']['session']) {
311
                    $this->action(
312
                        kernel::ACTION_TEMPORARILY_DENY,
313
                        kernel::REASON_TOO_MANY_SESSIONS
314
                    );
315
                    $isReject = true;
316
                }
317
            }
318
        }
319
320
321
        return [
322
            'is_flagged' => $isFlagged,
323
            'is_reject' => $isReject,
324
            'log_data' => $logData,
325
        ];
326
    }
327
328
    /**
329
     * Filter - Cookie
330
     *
331
     * @param array $logData   IP data from Shieldon log table.
332
     * @param array $ipData    The IP log data.
333
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
334
     *
335
     * @return array
336
     */
337
    protected function filterCookie(array $logData, array $ipDetail, bool $isFlagged): array
338
    {
339
        $isReject = false;
340
341
        // Let's checking cookie created by javascript..
342
        if ($this->filterStatus['cookie']) {
343
344
            // Get values from data table. We will count it and save it back to data table.
345
            $logData['flag_js_cookie'] = $ipDetail['flag_js_cookie'] ?? 0;
346
            $logData['pageviews_cookie'] = $ipDetail['pageviews_cookie'] ?? 0;
347
348
            $c = $this->properties['cookie_name'];
349
350
            $jsCookie = get_request()->getCookieParams()[$c] ?? 0;
351
352
            // Checking if a cookie is created by JavaScript.
353
            if (!empty($jsCookie)) {
354
355
                if ($jsCookie == '1') {
356
                    $logData['pageviews_cookie']++;
357
358
                } else {
359
                    // Flag it if the value is not 1.
360
                    $logData['flag_js_cookie']++;
361
                    $isFlagged = true;
362
                }
363
            } else {
364
                // If we cannot find the cookie, flag it.
365
                $logData['flag_js_cookie']++;
366
                $isFlagged = true;
367
            }
368
369
            if ($logData['flag_js_cookie'] > $this->properties['limit_unusual_behavior']['cookie']) {
370
371
                // Ban this IP if they reached the limit.
372
                $this->action(
373
                    kernel::ACTION_TEMPORARILY_DENY,
374
                    kernel::REASON_EMPTY_JS_COOKIE
375
                );
376
                $isReject = true;
377
            }
378
379
            // Remove JS cookie and reset.
380
            if ($logData['pageviews_cookie'] > $this->properties['limit_unusual_behavior']['cookie']) {
381
                $logData['pageviews_cookie'] = 0; // Reset to 0.
382
                $logData['flag_js_cookie'] = 0;
383
                unset_superglobal($c, 'cookie');
384
            }
385
        }
386
387
        return [
388
            'is_flagged' => $isFlagged,
389
            'is_reject' => $isReject,
390
            'log_data' => $logData,
391
        ];
392
    }
393
394
    /**
395
     * Filter - Frequency
396
     *
397
     * @param array $logData   IP data from Shieldon log table.
398
     * @param array $ipData    The IP log data.
399
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
400
     *
401
     * @return array
402
     */
403
    protected function filterFrequency(array $logData, array $ipDetail, bool $isFlagged): array
404
    {
405
        $isReject = false;
406
407
        if ($this->filterStatus['frequency']) {
408
            $timeSecond = [];
409
            $timeSecond['s'] = 1;
410
            $timeSecond['m'] = 60;
411
            $timeSecond['h'] = 3600;
412
            $timeSecond['d'] = 86400;
413
414
            foreach (array_keys($this->properties['time_unit_quota']) as $unit) {
415
416
                if (($logData['last_time'] - $ipDetail['first_time_' . $unit]) >= ($timeSecond[$unit] + 1)) {
417
418
                    // For example:
419
                    // (1) minutely: now > first_time_m about 61, (2) hourly: now > first_time_h about 3601, 
420
                    // Let's prepare to rest the the pageview count.
421
                    $this->filterResetStatus[$unit] = true;
422
423
                } else {
424
425
                    // If an user's pageview count is more than the time period limit
426
                    // He or she will get banned.
427
                    if ($logData['pageviews_' . $unit] > $this->properties['time_unit_quota'][$unit]) {
428
429
                        if ($unit === 's') {
430
                            $this->action(
431
                                kernel::ACTION_TEMPORARILY_DENY,
432
                                kernel::REASON_REACHED_LIMIT_SECOND
433
                            );
434
                        }
435
436
                        if ($unit === 'm') {
437
                            $this->action(
438
                                kernel::ACTION_TEMPORARILY_DENY,
439
                                kernel::REASON_REACHED_LIMIT_MINUTE
440
                            );
441
                        }
442
443
                        if ($unit === 'h') {
444
                            $this->action(
445
                                kernel::ACTION_TEMPORARILY_DENY,
446
                                kernel::REASON_REACHED_LIMIT_HOUR
447
                            );
448
                        }
449
450
                        if ($unit === 'd') {
451
                            $this->action(
452
                                kernel::ACTION_TEMPORARILY_DENY,
453
                                kernel::REASON_REACHED_LIMIT_DAY
454
                            );
455
                        }
456
457
                        $isReject = true;
458
                    }
459
                }
460
            }
461
462
            foreach ($this->filterResetStatus as $unit => $status) {
463
                // Reset the pageview check for specfic time unit.
464
                if ($status) {
465
                    $logData['first_time_' . $unit] = $logData['last_time'];
466
                    $logData['pageviews_' . $unit] = 0;
467
                }
468
            }
469
        }
470
471
        return [
472
            'is_flagged' => $isFlagged,
473
            'is_reject' => $isReject,
474
            'log_data' => $logData,
475
        ];
476
    }
477
}
478