Passed
Push — 2.x ( b1b8b2...a032c9 )
by Terry
02:05
created

Kernel::filterSession()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 35
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 16
c 1
b 0
f 0
nc 6
nop 3
dl 0
loc 35
rs 9.4222
1
<?php
2
/*
3
 * @name        Shieldon Firewall
4
 * @author      Terry Lin
5
 * @link        https://github.com/terrylinooo/shieldon
6
 * @package     Shieldon
7
 * @since       1.0.0
8
 * @version     2.0.0
9
 * @license     MIT
10
 *
11
 * Permission is hereby granted, free of charge, to any person obtaining a copy
12
 * of this software and associated documentation files (the "Software"), to deal
13
 * in the Software without restriction, including without limitation the rights
14
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
 * copies of the Software, and to permit persons to whom the Software is
16
 * furnished to do so, subject to the following conditions:
17
 *
18
 * The above copyright notice and this permission notice shall be included in
19
 * all copies or substantial portions of the Software.
20
 *
21
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27
 * THE SOFTWARE.
28
 */
29
30
declare(strict_types=1);
31
32
namespace Shieldon\Firewall;
33
34
use Psr\Http\Message\ServerRequestInterface;
35
use Psr\Http\Message\ResponseInterface;
36
use Shieldon\Firewall\Captcha\CaptchaInterface;
37
use Shieldon\Firewall\Captcha\Foundation;
38
use Shieldon\Firewall\Component\ComponentInterface;
39
use Shieldon\Firewall\Component\ComponentProvider;
40
use Shieldon\Firewall\Driver\DriverProvider;
41
use Shieldon\Firewall\Helpers;
42
use Shieldon\Firewall\HttpFactory;
43
use Shieldon\Firewall\Log\ActionLogger;
44
use Shieldon\Firewall\Utils\Container;
45
use Shieldon\Messenger\Messenger\MessengerInterface;
46
use function Shieldon\Firewall\__;
47
use function Shieldon\Firewall\get_cpu_usage;
48
use function Shieldon\Firewall\get_default_properties;
49
use function Shieldon\Firewall\get_memory_usage;
50
use function Shieldon\Firewall\get_request;
51
use function Shieldon\Firewall\get_response;
52
use function Shieldon\Firewall\get_session;
53
use function Shieldon\Firewall\unset_superglobal;
54
55
use Closure;
56
use InvalidArgumentException;
57
use LogicException;
58
use RuntimeException;
59
use function file_exists;
60
use function file_put_contents;
61
use function filter_var;
62
use function get_class;
63
use function gethostbyaddr;
64
use function is_dir;
65
use function is_writable;
66
use function microtime;
67
use function ob_end_clean;
68
use function ob_get_contents;
69
use function ob_start;
70
use function str_replace;
71
use function strpos;
72
use function strrpos;
73
use function substr;
74
use function time;
75
76
/**
77
 * The primary Shiendon class.
78
 */
79
class Kernel
80
{
81
    use IpTrait;
82
83
    // Reason codes (allow)
84
    const REASON_IS_SEARCH_ENGINE = 100;
85
    const REASON_IS_GOOGLE = 101;
86
    const REASON_IS_BING = 102;
87
    const REASON_IS_YAHOO = 103;
88
    const REASON_IS_SOCIAL_NETWORK = 110;
89
    const REASON_IS_FACEBOOK = 111;
90
    const REASON_IS_TWITTER = 112;
91
92
    // Reason codes (deny)
93
    const REASON_TOO_MANY_SESSIONS = 1;
94
    const REASON_TOO_MANY_ACCESSES = 2; // (not used)
95
    const REASON_EMPTY_JS_COOKIE = 3;
96
    const REASON_EMPTY_REFERER = 4;
97
    
98
    const REASON_REACHED_LIMIT_DAY = 11;
99
    const REASON_REACHED_LIMIT_HOUR = 12;
100
    const REASON_REACHED_LIMIT_MINUTE = 13;
101
    const REASON_REACHED_LIMIT_SECOND = 14;
102
103
    const REASON_INVALID_IP = 40;
104
    const REASON_DENY_IP = 41;
105
    const REASON_ALLOW_IP = 42;
106
107
    const REASON_COMPONENT_IP = 81;
108
    const REASON_COMPONENT_RDNS = 82;
109
    const REASON_COMPONENT_HEADER = 83;
110
    const REASON_COMPONENT_USERAGENT = 84;
111
    const REASON_COMPONENT_TRUSTED_ROBOT = 85;
112
113
    const REASON_MANUAL_BAN = 99;
114
115
    // Action codes
116
    const ACTION_DENY = 0;
117
    const ACTION_ALLOW = 1;
118
    const ACTION_TEMPORARILY_DENY = 2;
119
    const ACTION_UNBAN = 9;
120
121
    // Result codes
122
    const RESPONSE_DENY = 0;
123
    const RESPONSE_ALLOW = 1;
124
    const RESPONSE_TEMPORARILY_DENY = 2;
125
    const RESPONSE_LIMIT_SESSION = 3;
126
127
    const LOG_LIMIT = 3;
128
    const LOG_PAGEVIEW = 11;
129
    const LOG_BLACKLIST = 98;
130
    const LOG_CAPTCHA = 99;
131
132
    const KERNEL_DIR = __DIR__;
133
134
    /**
135
     * Driver for storing data.
136
     *
137
     * @var \Shieldon\Firewall\Driver\DriverProvider
138
     */
139
    public $driver;
140
141
    /**
142
     * Container for Shieldon components.
143
     *
144
     * @var array
145
     */
146
    public $component = [];
147
148
    /**
149
     * Logger instance.
150
     *
151
     * @var ActionLogger
152
     */
153
    public $logger;
154
155
    /**
156
     * The closure functions that will be executed in this->run()
157
     *
158
     * @var array
159
     */
160
    protected $closures = [];
161
162
    /**
163
     * Enable or disable the filters.
164
     *
165
     * @var array
166
     */
167
    protected $filterStatus = [
168
        /**
169
         * Check how many pageviews an user made in a short period time.
170
         * For example, limit an user can only view 30 pages in 60 minutes.
171
         */
172
        'frequency' => true,
173
174
        /**
175
         * If an user checks any internal link on your website, the user's
176
         * browser will generate HTTP_REFERER information.
177
         * When a user view many pages without HTTP_REFERER information meaning
178
         * that the user MUST be a web crawler.
179
         */
180
        'referer' => false,
181
182
        /**
183
         * Most of web crawlers do not render JavaScript, they only get the 
184
         * content they want, so we can check whether the cookie can be created
185
         * by JavaScript or not.
186
         */
187
        'cookie' => false,
188
189
        /**
190
         * Every unique user should only has a unique session, but if a user
191
         * creates different sessions every connection... meaning that the 
192
         * user's browser doesn't support cookie.
193
         * It is almost impossible that modern browsers not support cookie,
194
         * therefore the user MUST be a web crawler.
195
         */
196
        'session' => false,
197
    ];
198
199
    /**
200
     * default settings
201
     *
202
     * @var array
203
     */
204
    protected $properties = [];
205
206
    /**
207
     * This is for creating data tables automatically
208
     * Turn it off, if you don't want to check data tables every connection.
209
     *
210
     * @var bool
211
     */
212
    protected $autoCreateDatabase = true;
213
214
    /**
215
     * Container for captcha addons.
216
     * The collection of \Shieldon\Firewall\Captcha\CaptchaInterface
217
     *
218
     * @var array
219
     */
220
    protected $captcha = [];
221
222
    /**
223
     * The ways Shieldon send a message to when someone has been blocked.
224
     * The collection of \Shieldon\Messenger\Messenger\MessengerInterface
225
     *
226
     * @var array
227
     */
228
    protected $messenger = [];
229
230
    /**
231
     * If the IP is in the rule table, the rule status will change.
232
     *
233
     * @var array
234
     */
235
    protected $ruleStatus = [
236
237
        // IP is marked as allow in the rule table.
238
        'allow' => false,
239
240
        // IP is marked as deny in the rule table.
241
        'deny' => false,
242
    ];
243
244
    /**
245
     * Is to limit traffic?
246
     *
247
     * @var array
248
     */
249
    protected $sessionLimit = [
250
251
        // How many sessions will be available?
252
        // 0 = no limit.
253
        'count' => 0,
254
255
        // How many minutes will a session be availe to visit?
256
        // 0 = no limit.
257
        'period' => 0, 
258
    ];
259
260
    /**
261
     * Record the online session status.
262
     * This will be enabled when $sessionLimit[count] > 0
263
     *
264
     * @var array
265
     */
266
    protected $sessionStatus = [
267
268
        // Online session count.
269
        'count' => 0,
270
271
        // Current session order.
272
        'order' => 0,
273
274
        // Current waiting queue.
275
        'queue' => 0,
276
    ];
277
278
    /**
279
     * Result.
280
     *
281
     * @var int
282
     */
283
    protected $result = 1;
284
285
    /**
286
     * URLs that are excluded from Shieldon's protection.
287
     *
288
     * @var array
289
     */
290
    protected $excludedUrls = [];
291
292
    /**
293
     * Which type of configuration source that Shieldon firewall managed?
294
     *
295
     * @var string
296
     */
297
    protected $firewallType = 'self'; // managed | config | self | demo
298
299
    /**
300
     * Custom dialog UI settings.
301
     *
302
     * @var array
303
     */
304
    protected $dialogUI = [];
305
306
    /**
307
     * Store the class information used in Shieldon.
308
     *
309
     * @var array
310
     */
311
    protected $registrar = [];
312
313
    /**
314
     * Strict mode.
315
     * 
316
     * Set by `strictMode()` only. The default value of this propertry is undefined.
317
     *
318
     * @var bool
319
     */
320
    protected $strictMode;
321
322
    /**
323
     * The directory in where the frontend template files are placed.
324
     *
325
     * @var string
326
     */
327
    protected $templateDirectory = '';
328
329
    /**
330
     * The message that will be sent to the third-party API.
331
     *
332
     * @var string
333
     */
334
    protected $msgBody = '';
335
336
    /**
337
     * Shieldon constructor.
338
     * 
339
     * @param ServerRequestInterface|null $request  A PSR-7 server request.
340
     * 
341
     * @return void
342
     */
343
    public function __construct(?ServerRequestInterface $request  = null, ?ResponseInterface $response = null)
344
    {
345
        // Load helper functions. This is the must.
346
        new Helpers();
347
348
        if (is_null($request)) {
349
            $request = HttpFactory::createRequest();
350
        }
351
352
        if (is_null($response)) {
353
            $response = HttpFactory::createResponse();
354
        }
355
356
        $session = HttpFactory::createSession();
357
358
        $this->properties = get_default_properties();
359
        $this->add(new Foundation());
360
361
        Container::set('request', $request);
362
        Container::set('response', $response);
363
        Container::set('session', $session);
364
        Container::set('shieldon', $this);
365
    }
366
367
    /**
368
     * Log actions.
369
     *
370
     * @param int $actionCode The code number of the action.
371
     *
372
     * @return void
373
     */
374
    protected function log(int $actionCode): void
375
    {
376
        if (null !== $this->logger) {
377
            $logData = [];
378
            $logData['ip'] = $this->getIp();
379
            $logData['session_id'] = get_session()->get('id');
380
            $logData['action_code'] = $actionCode;
381
            $logData['timesamp'] = time();
382
    
383
            $this->logger->add($logData);
384
        }
385
    }
386
387
    /**
388
     * Run, run, run!
389
     *
390
     * Check the rule tables first, if an IP address has been listed.
391
     * Call function filter() if an IP address is not listed in rule tables.
392
     *
393
     * @return int The response code.
394
     */
395
    protected function process(): int
396
    {
397
        $this->driver->init($this->autoCreateDatabase);
398
399
        foreach (array_keys($this->component) as $name) {
400
            $this->component[$name]->setIp($this->ip);
401
            $this->component[$name]->setRdns($this->rdns);
402
403
            // Apply global strict mode to all components by `strictMode()` if nesscessary.
404
            if (isset($this->strictMode)) {
405
                $this->component[$name]->setStrict($this->strictMode);
406
            }
407
        }
408
409
        /*
410
        |--------------------------------------------------------------------------
411
        | Stage - Looking for rule table.
412
        |--------------------------------------------------------------------------
413
        */
414
415
        $ipRule = $this->driver->get($this->ip, 'rule');
416
417
        if (!empty($ipRule)) {
418
419
            $ruleType = (int) $ipRule['type'];
420
421
            if ($ruleType === self::ACTION_ALLOW) {
422
                $this->ruleStatus['allow'] = true;
423
                
424
            } else {
425
                
426
                // Current visitor has been blocked. If he still attempts accessing the site, 
427
                // then we can drop him into the permanent block list.
428
                $attempts = $ipRule['attempts'] ?? 0;
429
                $now = time();
430
                $logData = [];
431
432
                $logData['log_ip']     = $ipRule['log_ip'];
433
                $logData['ip_resolve'] = $ipRule['ip_resolve'];
434
                $logData['time']       = $now;
435
                $logData['type']       = $ipRule['type'];
436
                $logData['reason']     = $ipRule['reason'];
437
                $logData['attempts']   = $attempts;
438
439
                // @since 0.2.0
440
                $attemptPeriod = $this->properties['record_attempt_detection_period'];
441
                $attemptReset  = $this->properties['reset_attempt_counter'];
442
443
                $lastTimeDiff = $now - $ipRule['time'];
444
445
                if ($lastTimeDiff <= $attemptPeriod) {
446
                    $logData['attempts'] = ++$attempts;
447
                }
448
449
                if ($lastTimeDiff > $attemptReset) {
450
                    $logData['attempts'] = 0;
451
                }
452
453
                $isMessengerTriggered = false;
454
                $isUpdatRuleTable = false;
455
456
                $handleType = 0;
457
458
                if ($this->properties['deny_attempt_enable']['data_circle']) {
459
460
                    if ($ruleType === self::ACTION_TEMPORARILY_DENY) {
461
462
                        $isUpdatRuleTable = true;
463
464
                        $buffer = $this->properties['deny_attempt_buffer']['data_circle'];
465
466
                        if ($attempts >= $buffer) {
467
468
                            if ($this->properties['deny_attempt_notify']['data_circle']) {
469
                                $isMessengerTriggered = true;
470
                            }
471
472
                            $logData['type'] = self::ACTION_DENY;
473
474
                            // Reset this value for next checking process - iptables.
475
                            $logData['attempts'] = 0;
476
                            $handleType = 1;
477
                        }
478
                    }
479
                }
480
481
                if ($this->properties['deny_attempt_enable']['system_firewall']) {
482
                    
483
                    if ($ruleType === self::ACTION_DENY) {
484
485
                        $isUpdatRuleTable = true;
486
487
                        // For the requests that are already banned, but they are still attempting access, that means 
488
                        // that they are programmably accessing your website. Consider put them in the system-layer fireall
489
                        // such as IPTABLE.
490
                        $bufferIptable = $this->properties['deny_attempt_buffer']['system_firewall'];
491
492
                        if ($attempts >= $bufferIptable) {
493
494
                            if ($this->properties['deny_attempt_notify']['system_firewall']) {
495
                                $isMessengerTriggered = true;
496
                            }
497
498
                            $folder = rtrim($this->properties['iptables_watching_folder'], '/');
499
500
                            if (file_exists($folder) && is_writable($folder)) {
501
                                $filePath = $folder . '/iptables_queue.log';
502
503
                                // command, ipv4/6, ip, subnet, port, protocol, action
504
                                // add,4,127.0.0.1,null,all,all,drop  (example)
505
                                // add,4,127.0.0.1,null,80,tcp,drop   (example)
506
                                $command = 'add,4,' . $this->ip . ',null,all,all,deny';
507
508
                                if (filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
509
                                    $command = 'add,6,' . $this->ip . ',null,all,allow';
510
                                }
511
512
                                // Add this IP address to itables_queue.log
513
                                // Use `bin/iptables.sh` for adding it into IPTABLES. See document for more information. 
514
                                file_put_contents($filePath, $command . "\n", FILE_APPEND | LOCK_EX);
515
516
                                $logData['attempts'] = 0;
517
                                $handleType = 2;
518
                            }
519
                        }
520
                    }
521
                }
522
523
                // We only update data when `deny_attempt_enable` is enable.
524
                // Because we want to get the last visited time and attempt counter.
525
                // Otherwise we don't update it everytime to avoid wasting CPU resource.
526
                if ($isUpdatRuleTable) {
527
                    $this->driver->save($this->ip, $logData, 'rule');
528
                }
529
530
                /**
531
                 * Notify this event to messenger.
532
                 */
533
                if ($isMessengerTriggered) {
534
535
                    // The data strings that will be appended to message body.
536
                    $prepareMessageData = [
537
                        __('core', 'messenger_text_ip')       => $logData['log_ip'],
538
                        __('core', 'messenger_text_rdns')     => $logData['ip_resolve'],
539
                        __('core', 'messenger_text_reason')   => __('core', 'messenger_text_reason_code_' . $logData['reason']),
540
                        __('core', 'messenger_text_handle')   => __('core', 'messenger_text_handle_type_' . $handleType),
541
                        __('core', 'messenger_text_system')   => '',
542
                        __('core', 'messenger_text_cpu')      => get_cpu_usage(),
543
                        __('core', 'messenger_text_memory')   => get_memory_usage(),
544
                        __('core', 'messenger_text_time')     => date('Y-m-d H:i:s', $logData['time']),
545
                        __('core', 'messenger_text_timezone') => date_default_timezone_get(),
546
                    ];
547
548
                    $message = __('core', 'messenger_notification_subject', 'Notification for {0}', [$this->ip]) . "\n\n";
549
550
                    foreach ($prepareMessageData as $key => $value) {
551
                        $message .= $key . ': ' . $value . "\n";
552
                    }
553
554
                    $this->msgBody = $message;
555
                }
556
557
                // For an incoming request already in the rule list, return the rule type immediately.
558
                return $this->result = $ruleType;
559
            }
560
        }
561
562
        if ($this->ruleStatus['allow']) {
563
564
            // The requests that are allowed in rule table will not go into sessionHandler.
565
            return $this->result = self::RESPONSE_ALLOW;
566
567
        } else {
568
569
            /*
570
            |--------------------------------------------------------------------------
571
            | Statge - Detect popular search engine.
572
            |--------------------------------------------------------------------------
573
            */
574
575
            if ($this->getComponent('TrustedBot')) {
576
 
577
                // We want to put all the allowed robot into the rule list, so that the checking of IP's resolved hostname 
578
                // is no more needed for that IP.
579
                if ($this->getComponent('TrustedBot')->isAllowed()) {
0 ignored issues
show
Bug introduced by
The method isAllowed() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\Ip or Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

579
                if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isAllowed()) {
Loading history...
580
581
                    if ($this->getComponent('TrustedBot')->isGoogle()) {
0 ignored issues
show
Bug introduced by
The method isGoogle() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

581
                    if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isGoogle()) {
Loading history...
582
                        // Add current IP into allowed list, because it is from real Google domain.
583
                        $this->action(
584
                            self::ACTION_ALLOW,
585
                            self::REASON_IS_GOOGLE
586
                        );
587
588
                    } elseif ($this->getComponent('TrustedBot')->isBing()) {
0 ignored issues
show
Bug introduced by
The method isBing() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

588
                    } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isBing()) {
Loading history...
589
                        // Add current IP into allowed list, because it is from real Bing domain.
590
                        $this->action(
591
                            self::ACTION_ALLOW,
592
                            self::REASON_IS_BING
593
                        );
594
595
                    } elseif ($this->getComponent('TrustedBot')->isYahoo()) {
0 ignored issues
show
Bug introduced by
The method isYahoo() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

595
                    } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isYahoo()) {
Loading history...
596
                        // Add current IP into allowed list, because it is from real Yahoo domain.
597
                        $this->action(
598
                            self::ACTION_ALLOW,
599
                            self::REASON_IS_YAHOO
600
                        );
601
602
                    } else {
603
                        // Add current IP into allowed list, because you trust it.
604
                        // You have already defined it in the settings.
605
                        $this->action(
606
                            self::ACTION_ALLOW,
607
                            self::REASON_IS_SEARCH_ENGINE
608
                        );
609
                    }
610
                    // Allowed robots not join to our traffic handler.
611
                    return $this->result = self::RESPONSE_ALLOW;
612
                }
613
614
                // After `isAllowed()` executed, we can check if the currect access is fake by `isFakeRobot()`.
615
                if ($this->getComponent('TrustedBot')->isFakeRobot()) {
0 ignored issues
show
Bug introduced by
The method isFakeRobot() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

615
                if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isFakeRobot()) {
Loading history...
616
                    $this->action(
617
                        self::ACTION_DENY,
618
                        self::REASON_COMPONENT_TRUSTED_ROBOT
619
                    );
620
621
                    return $this->result = self::RESPONSE_DENY;
622
                }
623
            }
624
625
            /*
626
            |--------------------------------------------------------------------------
627
            | Stage - IP component.
628
            |--------------------------------------------------------------------------
629
            */
630
631
            if ($this->getComponent('Ip')) {
632
633
                $result = $this->getComponent('Ip')->check();
0 ignored issues
show
Bug introduced by
The method check() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\Ip. ( Ignorable by Annotation )

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

633
                $result = $this->getComponent('Ip')->/** @scrutinizer ignore-call */ check();
Loading history...
634
                $actionCode = self::ACTION_DENY;
635
636
                if (!empty($result)) {
637
638
                    switch ($result['status']) {
639
640
                        case 'allow':
641
                            $actionCode = self::ACTION_ALLOW;
642
                            $reasonCode = $result['code'];
643
                            break;
644
        
645
                        case 'deny':
646
                            $actionCode = self::ACTION_DENY;
647
                            $reasonCode = $result['code']; 
648
                            break;
649
                    }
650
    
651
                    $this->action($actionCode, $reasonCode);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $reasonCode does not seem to be defined for all execution paths leading up to this point.
Loading history...
652
    
653
                    // $resultCode = $actionCode
654
                    return $this->result = $this->sessionHandler($actionCode);
655
                }
656
            }
657
    
658
            /*
659
            |--------------------------------------------------------------------------
660
            | Stage - Check all other components.
661
            |--------------------------------------------------------------------------
662
            */
663
664
            foreach ($this->component as $component) {
665
    
666
                // check if is a a bad robot already defined in settings.
667
                if ($component->isDenied()) {
668
    
669
                    // @since 0.1.8
670
                    $this->action(
671
                        self::ACTION_DENY,
672
                        $component->getDenyStatusCode()
673
                    );
674
    
675
                    return $this->result = self::RESPONSE_DENY;
676
                }
677
            }
678
        }
679
680
        /*
681
        |--------------------------------------------------------------------------
682
        | Stage - Filters
683
        |--------------------------------------------------------------------------
684
        | This IP address is not listed in rule table, let's detect it.
685
        |
686
        */
687
688
        if (
689
            $this->filterStatus['frequency'] ||
690
            $this->filterStatus['referer'] ||
691
            $this->filterStatus['session'] ||
692
            $this->filterStatus['cookie']
693
        ) {
694
            return $this->result = $this->sessionHandler($this->filter());
695
        }
696
697
        return $this->result = $this->sessionHandler(self::RESPONSE_ALLOW);
698
    }
699
700
    /**
701
     * Detect and analyze an user's behavior.
702
     *
703
     * @return int The response code.
704
     */
705
    protected function filter(): int
706
    {
707
        $now = time();
708
        $isFlagged = false;
709
710
        $resetPageviews = [
711
            's' => false, // second.
712
            'm' => false, // minute.
713
            'h' => false, // hour.
714
            'd' => false, // day.
715
        ];
716
717
        // Fetch an IP data from Shieldon log table.
718
        $ipDetail = $this->driver->get($this->ip, 'filter_log');
719
720
        $ipDetail = $this->driver->parseData($ipDetail, 'filter_log');
721
        $logData = [];
722
723
        // Counting user pageviews.
724
        foreach (array_keys($resetPageviews) as $unit) {
725
726
            // Each time unit will increase by 1.
727
            $logData["pageviews_{$unit}"] = $ipDetail["pageviews_{$unit}"] + 1;
728
            $logData["first_time_{$unit}"] = $ipDetail["first_time_{$unit}"];
729
        }
730
731
        $logData['first_time_flag'] = $ipDetail['first_time_flag'];
732
733
        if (!empty($ipDetail['ip'])) {
734
            $logData['ip']        = $this->ip;
735
            $logData['session']   = get_session()->get('id');
736
            $logData['hostname']  = $this->rdns;
737
            $logData['last_time'] = $now;
738
739
            /*
740
            |--------------------------------------------------------------------------
741
            | BEGIN
742
            |--------------------------------------------------------------------------
743
            */
744
745
            // HTTP_REFERER
746
            $filterReferer = $this->filterReferer($logData, $ipDetail, $isFlagged);
747
            $isFlagged = $filterReferer['is_flagged'];
748
            $logData = $filterReferer['log_data'];
749
750
            if ($filterReferer['is_reject']) {
751
                return self::RESPONSE_TEMPORARILY_DENY;
752
            }
753
754
            // SESSION
755
            $filterSession = $this->filterSession($logData, $ipDetail, $isFlagged);
756
            $isFlagged = $filterSession['is_flagged'];
757
            $logData = $filterSession['log_data'];
758
759
            if ($filterSession['is_reject']) {
760
                return self::RESPONSE_TEMPORARILY_DENY;
761
            }
762
763
            // JAVASCRIPT COOKIE
764
            $filterCookie = $this->filterCookie($logData, $ipDetail, $isFlagged);
765
            $isFlagged = $filterCookie['is_flagged'];
766
            $logData = $filterCookie['log_data'];
767
768
            if ($filterCookie['is_reject']) {
769
                return self::RESPONSE_TEMPORARILY_DENY;
770
            }
771
772
            // ACCESS FREQUENCY
773
            $filterFrequency = $this->filterFrequency($logData, $ipDetail, $isFlagged, $resetPageviews);
774
            $isFlagged = $filterFrequency['is_flagged'];
775
            $logData = $filterFrequency['log_data'];
776
777
            if ($filterFrequency['is_reject']) {
778
                return self::RESPONSE_TEMPORARILY_DENY;
779
            }
780
781
            /*
782
            |--------------------------------------------------------------------------
783
            | END
784
            |--------------------------------------------------------------------------
785
            */
786
787
            // Is fagged as unusual beavior? Count the first time.
788
            if ($isFlagged) {
789
                $logData['first_time_flag'] = (!empty($logData['first_time_flag'])) ? $logData['first_time_flag'] : $now;
790
            }
791
792
            // Reset the flagged factor check.
793
            if (!empty($ipDetail['first_time_flag'])) {
794
                if ($now - $ipDetail['first_time_flag'] >= $this->properties['time_reset_limit']) {
795
                    $logData['flag_multi_session'] = 0;
796
                    $logData['flag_empty_referer'] = 0;
797
                    $logData['flag_js_cookie']     = 0;
798
                }
799
            }
800
801
            $this->driver->save($this->ip, $logData, 'filter_log');
802
803
        } else {
804
805
            // If $ipDetail[ip] is empty.
806
            // It means that the user is first time visiting our webiste.
807
808
            $logData['ip']        = $this->ip;
809
            $logData['session']   = get_session()->get('id');
810
            $logData['hostname']  = $this->rdns;
811
            $logData['last_time'] = $now;
812
813
            foreach (array_keys($resetPageviews) as $unit) {
814
                $logData['first_time_' . $unit] = $now;
815
            }
816
817
            $this->driver->save($this->ip, $logData, 'filter_log');
818
        }
819
820
        return self::RESPONSE_ALLOW;
821
    }
822
823
    /**
824
     * Start an action for this IP address, allow or deny, and give a reason for it.
825
     *
826
     * @param int    $actionCode - 0: deny, 1: allow, 9: unban.
827
     * @param string $reasonCode
828
     * @param string $assignIp
829
     * 
830
     * @return void
831
     */
832
    protected function action(int $actionCode, int $reasonCode, string $assignIp = ''): void
833
    {
834
        $ip = $this->ip;
835
        $rdns = $this->rdns;
836
        $now = time();
837
        $logData = [];
838
    
839
        if ('' !== $assignIp) {
840
            $ip = $assignIp;
841
            $rdns = gethostbyaddr($ip);
842
        }
843
844
        switch ($actionCode) {
845
            case self::ACTION_ALLOW: // acutally not used.
846
            case self::ACTION_DENY:  // actually not used.
847
            case self::ACTION_TEMPORARILY_DENY:
848
                $logData['log_ip']     = $ip;
849
                $logData['ip_resolve'] = $rdns;
850
                $logData['time']       = $now;
851
                $logData['type']       = $actionCode;
852
                $logData['reason']     = $reasonCode;
853
                $logData['attempts']   = 0;
854
855
                $this->driver->save($ip, $logData, 'rule');
856
                break;
857
            
858
            case self::ACTION_UNBAN:
859
                $this->driver->delete($ip, 'rule');
860
                break;
861
        }
862
863
        // Remove logs for this IP address because It already has it's own rule on system.
864
        // No need to count it anymore.
865
        $this->driver->delete($ip, 'filter_log');
866
867
        if (null !== $this->logger) {
868
            $log['ip']          = $ip;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$log was never initialized. Although not strictly required by PHP, it is generally a good practice to add $log = array(); before regardless.
Loading history...
869
            $log['session_id']  = get_session()->get('id');
870
            $log['action_code'] = $actionCode;
871
            $log['timesamp']    = $now;
872
873
            $this->logger->add($log);
874
        }
875
    }
876
877
    /**
878
     * Deal with online sessions.
879
     *
880
     * @param int $statusCode The response code.
881
     *
882
     * @return int The response code.
883
     */
884
    protected function sessionHandler($statusCode): int
885
    {
886
        if (self::RESPONSE_ALLOW !== $statusCode) {
887
            return $statusCode;
888
        }
889
890
        // If you don't enable `limit traffic`, ignore the following steps.
891
        if (empty($this->sessionLimit['count'])) {
892
            return self::RESPONSE_ALLOW;
893
894
        } else {
895
896
            // Get the proerties.
897
            $limit = (int) ($this->sessionLimit['count'] ?? 0);
898
            $period = (int) ($this->sessionLimit['period'] ?? 300);
899
            $now = time();
900
901
            $sessionData = $this->driver->getAll('session');
902
            $sessionPools = [];
903
904
            $i = 1;
905
            $sessionOrder = 0;
906
907
            if (!empty($sessionData)) {
908
                foreach ($sessionData as $v) {
909
                    $sessionPools[] = $v['id'];
910
                    $lasttime = (int) $v['time'];
911
    
912
                    if (get_session()->get('id') === $v['id']) {
913
                        $sessionOrder = $i;
914
                    }
915
    
916
                    // Remove session if it expires.
917
                    if ($now - $lasttime > $period) {
918
                        $this->driver->delete($v['id'], 'session');
919
                    }
920
                    $i++;
921
                }
922
923
                if (0 === $sessionOrder) {
924
                    $sessionOrder = $i;
925
                }
926
            } else {
927
                $sessionOrder = 0;
928
            }
929
930
            // Count the online sessions.
931
            $this->sessionStatus['count'] = count($sessionPools);
932
            $this->sessionStatus['order'] = $sessionOrder;
933
            $this->sessionStatus['queue'] = $sessionOrder - $limit;
934
935
            if (!in_array(get_session()->get('id'), $sessionPools)) {
936
                $this->sessionStatus['count']++;
937
938
                // New session, record this data.
939
                $data['id'] = get_session()->get('id');
0 ignored issues
show
Comprehensibility Best Practice introduced by
$data was never initialized. Although not strictly required by PHP, it is generally a good practice to add $data = array(); before regardless.
Loading history...
940
                $data['ip'] = $this->ip;
941
                $data['time'] = $now;
942
943
                $microtimesamp = explode(' ', microtime());
944
                $microtimesamp = $microtimesamp[1] . str_replace('0.', '', $microtimesamp[0]);
945
                $data['microtimesamp'] = $microtimesamp;
946
947
                $this->driver->save(get_session()->get('id'), $data, 'session');
948
            }
949
950
            // Online session count reached the limit. So return RESPONSE_LIMIT_SESSION response code.
951
            if ($sessionOrder >= $limit) {
952
                return self::RESPONSE_LIMIT_SESSION;
953
            }
954
        }
955
956
        return self::RESPONSE_ALLOW;
957
    }
958
959
/**
960
     * Filter - Referer.
961
     *
962
     * @param array $logData   IP data from Shieldon log table.
963
     * @param array $ipData    The IP log data.
964
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
965
     *
966
     * @return array
967
     */
968
    protected function filterReferer(array $logData, array $ipDetail, bool $isFlagged): array
969
    {
970
        $isReject = false;
971
972
        if ($this->filterStatus['referer']) {
973
974
            if ($logData['last_time'] - $ipDetail['last_time'] > $this->properties['interval_check_referer']) {
975
976
                // Get values from data table. We will count it and save it back to data table.
977
                // If an user is already in your website, it is impossible no referer when he views other pages.
978
                $logData['flag_empty_referer'] = $ipDetail['flag_empty_referer'] ?? 0;
979
980
                if (empty(get_request()->getHeaderLine('referer'))) {
981
                    $logData['flag_empty_referer']++;
982
                    $isFlagged = true;
983
                }
984
985
                // Ban this IP if they reached the limit.
986
                if ($logData['flag_empty_referer'] > $this->properties['limit_unusual_behavior']['referer']) {
987
                    $this->action(
988
                        self::ACTION_TEMPORARILY_DENY,
989
                        self::REASON_EMPTY_REFERER
990
                    );
991
                    $isReject = true;
992
                }
993
            }
994
        }
995
996
        return [
997
            'is_flagged' => $isFlagged,
998
            'is_reject' => $isReject,
999
            'log_data' => $logData,
1000
        ];
1001
    }
1002
1003
    /**
1004
     * Filter - Session
1005
     *
1006
     * @param array $logData   IP data from Shieldon log table.
1007
     * @param array $ipData    The IP log data.
1008
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
1009
     *
1010
     * @return array
1011
     */
1012
    protected function filterSession(array $logData, array $ipDetail, bool $isFlagged): array
1013
    {
1014
        $isReject = false;
1015
1016
        if ($this->filterStatus['session']) {
1017
1018
            if ($logData['last_time'] - $ipDetail['last_time'] > $this->properties['interval_check_session']) {
1019
1020
                // Get values from data table. We will count it and save it back to data table.
1021
                $logData['flag_multi_session'] = $ipDetail['flag_multi_session'] ?? 0;
1022
                
1023
                if (get_session()->get('id') !== $ipDetail['session']) {
1024
1025
                    // Is is possible because of direct access by the same user many times.
1026
                    // Or they don't have session cookie set.
1027
                    $logData['flag_multi_session']++;
1028
                    $isFlagged = true;
1029
                }
1030
1031
                // Ban this IP if they reached the limit.
1032
                if ($logData['flag_multi_session'] > $this->properties['limit_unusual_behavior']['session']) {
1033
                    $this->action(
1034
                        self::ACTION_TEMPORARILY_DENY,
1035
                        self::REASON_TOO_MANY_SESSIONS
1036
                    );
1037
                    $isReject = true;
1038
                }
1039
            }
1040
        }
1041
1042
1043
        return [
1044
            'is_flagged' => $isFlagged,
1045
            'is_reject' => $isReject,
1046
            'log_data' => $logData,
1047
        ];
1048
    }
1049
1050
    /**
1051
     * Filter - Cookie
1052
     *
1053
     * @param array $logData   IP data from Shieldon log table.
1054
     * @param array $ipData    The IP log data.
1055
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
1056
     *
1057
     * @return array
1058
     */
1059
    protected function filterCookie(array $logData, array $ipDetail, bool $isFlagged): array
1060
    {
1061
        $isReject = false;
1062
1063
        // Let's checking cookie created by javascript..
1064
        if ($this->filterStatus['cookie']) {
1065
1066
            // Get values from data table. We will count it and save it back to data table.
1067
            $logData['flag_js_cookie'] = $ipDetail['flag_js_cookie'] ?? 0;
1068
            $logData['pageviews_cookie'] = $ipDetail['pageviews_cookie'] ?? 0;
1069
1070
            $c = $this->properties['cookie_name'];
1071
1072
            $jsCookie = get_request()->getCookieParams()[$c] ?? 0;
1073
1074
            // Checking if a cookie is created by JavaScript.
1075
            if (!empty($jsCookie)) {
1076
1077
                if ($jsCookie == '1') {
1078
                    $logData['pageviews_cookie']++;
1079
1080
                } else {
1081
                    // Flag it if the value is not 1.
1082
                    $logData['flag_js_cookie']++;
1083
                    $isFlagged = true;
1084
                }
1085
            } else {
1086
                // If we cannot find the cookie, flag it.
1087
                $logData['flag_js_cookie']++;
1088
                $isFlagged = true;
1089
            }
1090
1091
            if ($logData['flag_js_cookie'] > $this->properties['limit_unusual_behavior']['cookie']) {
1092
1093
                // Ban this IP if they reached the limit.
1094
                $this->action(
1095
                    self::ACTION_TEMPORARILY_DENY,
1096
                    self::REASON_EMPTY_JS_COOKIE
1097
                );
1098
                $isReject = true;
1099
            }
1100
1101
            // Remove JS cookie and reset.
1102
            if ($logData['pageviews_cookie'] > $this->properties['limit_unusual_behavior']['cookie']) {
1103
                $logData['pageviews_cookie'] = 0; // Reset to 0.
1104
                $logData['flag_js_cookie'] = 0;
1105
                unset_superglobal($c, 'cookie');
1106
            }
1107
        }
1108
1109
        return [
1110
            'is_flagged' => $isFlagged,
1111
            'is_reject' => $isReject,
1112
            'log_data' => $logData,
1113
        ];
1114
    }
1115
1116
    /**
1117
     * Filter - Frequency
1118
     *
1119
     * @param array $logData   IP data from Shieldon log table.
1120
     * @param array $ipData    The IP log data.
1121
     * @param bool  $isFlagged Is flagged as unusual behavior or not.
1122
     * @param array $units     The pageview unit status.
1123
     *
1124
     * @return array
1125
     */
1126
    protected function filterFrequency(array $logData, array $ipDetail, bool $isFlagged, array $units): array
1127
    {
1128
        $isReject = false;
1129
1130
        if ($this->filterStatus['frequency']) {
1131
            $timeSecond = 0;
1132
1133
            foreach (array_keys($this->properties['time_unit_quota']) as $unit) {
1134
                switch ($unit) {
1135
                    case 's': 
1136
                        $timeSecond = 1;
1137
                        break;
1138
1139
                    case 'm':
1140
                        $timeSecond = 60;
1141
                        break;
1142
1143
                    case 'h':
1144
                        $timeSecond = 3600;
1145
                        break;
1146
1147
                    case 'd':
1148
                        $timeSecond = 86400;
1149
                        break;
1150
                }
1151
1152
                if (($logData['last_time'] - $ipDetail['first_time_' . $unit]) >= ($timeSecond + 1)) {
1153
1154
                    // For example:
1155
                    // (1) minutely: now > first_time_m about 61, (2) hourly: now > first_time_h about 3601, 
1156
                    // Let's prepare to rest the the pageview count.
1157
                    $units[$unit] = true;
1158
1159
                } else {
1160
1161
                    // If an user's pageview count is more than the time period limit
1162
                    // He or she will get banned.
1163
                    if ($logData['pageviews_' . $unit] > $this->properties['time_unit_quota'][$unit]) {
1164
1165
                        if ($unit === 's') {
1166
                            $this->action(
1167
                                self::ACTION_TEMPORARILY_DENY,
1168
                                self::REASON_REACHED_LIMIT_SECOND
1169
                            );
1170
                        }
1171
1172
                        if ($unit === 'm') {
1173
                            $this->action(
1174
                                self::ACTION_TEMPORARILY_DENY,
1175
                                self::REASON_REACHED_LIMIT_MINUTE
1176
                            );
1177
                        }
1178
1179
                        if ($unit === 'h') {
1180
                            $this->action(
1181
                                self::ACTION_TEMPORARILY_DENY,
1182
                                self::REASON_REACHED_LIMIT_HOUR
1183
                            );
1184
                        }
1185
1186
                        if ($unit === 'd') {
1187
                            $this->action(
1188
                                self::ACTION_TEMPORARILY_DENY,
1189
                                self::REASON_REACHED_LIMIT_DAY
1190
                            );
1191
                        }
1192
1193
                        $isReject = true;
1194
                    }
1195
                }
1196
            }
1197
1198
            foreach ($units as $unit => $resetStatus) {
1199
                // Reset the pageview check for specfic time unit.
1200
                if ($resetStatus) {
1201
                    $logData['first_time_' . $unit] = $logData['last_time'];
1202
                    $logData['pageviews_' . $unit] = 0;
1203
                }
1204
            }
1205
        }
1206
1207
        return [
1208
            'is_flagged' => $isFlagged,
1209
            'is_reject' => $isReject,
1210
            'log_data' => $logData,
1211
        ];
1212
    }
1213
1214
    // @codeCoverageIgnoreStart
1215
1216
    /**
1217
     * For testing propose.
1218
     *
1219
     * @param string $sessionId
1220
     *
1221
     * @return void
1222
     */
1223
    protected function setSessionId(string $sessionId = ''): void
1224
    {
1225
        if ('' !== $sessionId) {
1226
            get_session()->set('id', $sessionId);
1227
        }
1228
    }
1229
1230
    // @codeCoverageIgnoreEnd
1231
1232
    /*
1233
    | -------------------------------------------------------------------
1234
    |                            Public APIs
1235
    | -------------------------------------------------------------------
1236
    */
1237
1238
    /**
1239
     * Register classes to Shieldon core.
1240
     * setDriver, setLogger, setComponent and setCaptcha are deprecated methods
1241
     * and no more used.
1242
     *
1243
     * @param object $instance Component classes that used on Shieldon.
1244
     *
1245
     * @return void
1246
     */
1247
    public function add($instance)
1248
    {
1249
        static $i = 2;
1250
1251
        $class = $this->getClassName($instance);
1252
1253
        if ($instance instanceof DriverProvider) {
1254
            $this->driver = $instance;
1255
            $this->registrar[0] = [
1256
                'category' => 'driver',
1257
                'class' => $class,
1258
            ];
1259
        }
1260
1261
        if ($instance instanceof ActionLogger) {
1262
            $this->logger = $instance;
1263
            $this->registrar[1] = [
1264
                'category' => 'logger',
1265
                'class' => $class,
1266
            ];
1267
        }
1268
1269
        if ($instance instanceof CaptchaInterface) {
1270
            $this->captcha[$class] = $instance;
1271
            $this->registrar[$i] = [
1272
                'category' => 'captcha',
1273
                'class' => $class,
1274
            ];
1275
            $i++;
1276
        }
1277
1278
        if ($instance instanceof ComponentProvider) {
1279
            $this->component[$class] = $instance;
1280
            $this->registrar[$i] = [
1281
                'category' => 'component',
1282
                'class' => $class,
1283
            ];
1284
            $i++;
1285
        }
1286
1287
        if ($instance instanceof MessengerInterface) {
1288
            $this->messenger[] = $instance;
1289
            $this->registrar[$i] = [
1290
                'category' => 'messenger',
1291
                'class' => $class,
1292
            ];
1293
            $i++;
1294
        }
1295
    }
1296
1297
    /**
1298
     * Remove registered classes from the Kernel.
1299
     *
1300
     * @param string $category  The class category.
1301
     * @param string $className The class name.
1302
     *
1303
     * @return void
1304
     */
1305
    public function remove(string $category, string $className = '')
1306
    {
1307
        if ($className !== '') {
1308
            foreach ($this->getRegistrar() as $k => $v) {
1309
                if ($category === $v['category'] && $className === $v['class']) {
1310
                    if (is_array($this->{$category})) {
1311
                        foreach ($this->{$category} as $k2 => $instance) {
1312
                            if ($this->getClassName($instance) === $className) {
1313
                                unset($this->{$category}[$k2]);
1314
                            }
1315
                        }
1316
                    } else {
1317
                        $this->{$category} = null;
1318
                    }
1319
                    unset($this->registrar[$k]);
1320
                }
1321
            }
1322
        } else {
1323
            foreach ($this->getRegistrar() as $k => $v) {
1324
                if ($category === $v['category']) {
1325
                    if (is_array($this->{$category})) {
1326
                        $this->{$category} = [];
1327
                    } else {
1328
                        $this->{$category} = null;
1329
                    }
1330
                    unset($this->registrar[$k]);
1331
                }
1332
            }
1333
        }
1334
    }
1335
1336
    /**
1337
     * Fetch the class list from registrar.
1338
     *
1339
     * @return array
1340
     */
1341
    public function getRegistrar(): array
1342
    {
1343
        return $this->registrar;
1344
    }
1345
1346
    /**
1347
     * Get a component instance from component's container.
1348
     *
1349
     * @param string $name The component's class name.
1350
     *
1351
     * @return ComponentInterface|null
1352
     */
1353
    public function getComponent(string $name)
1354
    {
1355
        if (isset($this->component[$name])) {
1356
            return $this->component[$name];
1357
        }
1358
1359
        return null;
1360
    }
1361
1362
    /**
1363
     * Strict mode.
1364
     * This option will take effects to all components.
1365
     * 
1366
     * @param bool $bool Set true to enble strict mode, false to disable it overwise.
1367
     *
1368
     * @return void
1369
     */
1370
    public function setStrict(bool $bool)
1371
    {
1372
        $this->strictMode = $bool;
1373
    }
1374
1375
    /**
1376
     * Disable filters.
1377
     */
1378
    public function disableFilters(): void
1379
    {
1380
        $this->setFilters([
1381
            'session'   => false,
1382
            'cookie'    => false,
1383
            'referer'   => false,
1384
            'frequency' => false,
1385
        ]);
1386
    }
1387
1388
    /**
1389
     * For first time installation only. This is for creating data tables automatically.
1390
     * Turning it on will check the data tables exist or not at every single pageview, 
1391
     * it's not good for high traffic websites.
1392
     *
1393
     * @param bool $bool
1394
     * 
1395
     * @return void
1396
     */
1397
    public function createDatabase(bool $bool)
1398
    {
1399
        $this->autoCreateDatabase = $bool;
1400
    }
1401
1402
    /**
1403
     * Set a data channel.
1404
     *
1405
     * This will create databases for the channel.
1406
     *
1407
     * @param string $channel Specify a channel.
1408
     *
1409
     * @return void
1410
     */
1411
    public function setChannel(string $channel)
1412
    {
1413
        if (!$this->driver instanceof DriverProvider) {
0 ignored issues
show
introduced by
$this->driver is always a sub-type of Shieldon\Firewall\Driver\DriverProvider.
Loading history...
1414
            throw new LogicException('setChannel method requires setDriver set first.');
1415
        } else {
1416
            $this->driver->setChannel($channel);
1417
        }
1418
    }
1419
1420
    /**
1421
     * Return the result from Captchas.
1422
     *
1423
     * @return bool
1424
     */
1425
    public function captchaResponse(): bool
1426
    {
1427
        foreach ($this->captcha as $captcha) {
1428
            
1429
            if (!$captcha->response()) {
1430
                return false;
1431
            }
1432
        }
1433
1434
        if (!empty($this->sessionLimit['count'])) {
1435
            $this->result = $this->sessionHandler(self::RESPONSE_ALLOW);
1436
        }
1437
1438
        return true;
1439
    }
1440
1441
    /**
1442
     * Ban an IP.
1443
     *
1444
     * @param string $ip A valid IP address.
1445
     *
1446
     * @return void
1447
     */
1448
    public function ban(string $ip = ''): void
1449
    {
1450
        if ('' === $ip) {
1451
            $ip = $this->ip;
1452
        }
1453
 
1454
        $this->action(
1455
            self::ACTION_DENY,
1456
            self::REASON_MANUAL_BAN, $ip
1457
        );
1458
    }
1459
1460
    /**
1461
     * Unban an IP.
1462
     *
1463
     * @param string $ip A valid IP address.
1464
     *
1465
     * @return void
1466
     */
1467
    public function unban(string $ip = ''): void
1468
    {
1469
        if ('' === $ip) {
1470
            $ip = $this->ip;
1471
        }
1472
1473
        $this->action(
1474
            self::ACTION_UNBAN,
1475
            self::REASON_MANUAL_BAN, $ip
1476
        );
1477
        $this->log(self::ACTION_UNBAN);
1478
1479
        $this->result = self::RESPONSE_ALLOW;
1480
    }
1481
1482
    /**
1483
     * Set a property setting.
1484
     *
1485
     * @param string $key   The key of a property setting.
1486
     * @param mixed  $value The value of a property setting.
1487
     *
1488
     * @return void
1489
     */
1490
    public function setProperty(string $key = '', $value = '')
1491
    {
1492
        if (isset($this->properties[$key])) {
1493
            $this->properties[$key] = $value;
1494
        }
1495
    }
1496
1497
    /**
1498
     * Set the property settings.
1499
     * 
1500
     * @param array $settings The settings.
1501
     *
1502
     * @return void
1503
     */
1504
    public function setProperties(array $settings): void
1505
    {
1506
        foreach (array_keys($this->properties) as $k) {
1507
            if (isset($settings[$k])) {
1508
                $this->properties[$k] = $settings[$k];
1509
            }
1510
        }
1511
    }
1512
1513
    /**
1514
     * Limt online sessions.
1515
     *
1516
     * @param int $count
1517
     * @param int $period
1518
     *
1519
     * @return void
1520
     */
1521
    public function limitSession(int $count = 1000, int $period = 300): void
1522
    {
1523
        $this->sessionLimit = [
1524
            'count' => $count,
1525
            'period' => $period
1526
        ];
1527
    }
1528
1529
    /**
1530
     * Customize the dialog UI.
1531
     *
1532
     * @return void
1533
     */
1534
    public function setDialogUI(array $settings): void
1535
    {
1536
        $this->dialogUI = $settings;
1537
    }
1538
1539
    /**
1540
     * Set the frontend template directory.
1541
     *
1542
     * @param string $directory
1543
     *
1544
     * @return void
1545
     */
1546
    public function setTemplateDirectory(string $directory)
1547
    {
1548
        if (!is_dir($directory)) {
1549
            throw new InvalidArgumentException('The template directory does not exist.');
1550
        }
1551
        $this->templateDirectory = $directory;
1552
    }
1553
1554
    /**
1555
     * Get a template PHP file.
1556
     *
1557
     * @param string $type The template type.
1558
     *
1559
     * @return string
1560
     */
1561
    protected function getTemplate(string $type): string
1562
    {
1563
        $directory = self::KERNEL_DIR . '/../../templates/frontend';
1564
1565
        if (!empty($this->templateDirectory)) {
1566
            $directory = $this->templateDirectory;
1567
        }
1568
1569
        $path = $directory . '/' . $type . '.php';
1570
1571
        if (!file_exists($path)) {
1572
            throw new RuntimeException(
1573
                sprintf(
1574
                    'The templeate file is missing. (%s)',
1575
                    $path
1576
                )
1577
            );
1578
        }
1579
1580
        return $path;
1581
    }
1582
1583
    /**
1584
     * Get a class name without namespace string.
1585
     *
1586
     * @param object $instance Class
1587
     * 
1588
     * @return void
1589
     */
1590
    protected function getClassName($instance): string
1591
    {
1592
        $class = get_class($instance);
1593
        return substr($class, strrpos($class, '\\') + 1); 
0 ignored issues
show
Bug Best Practice introduced by
The expression return substr($class, strrpos($class, '\') + 1) returns the type string which is incompatible with the documented return type void.
Loading history...
1594
    }
1595
1596
    /**
1597
     * Respond the result.
1598
     *
1599
     * @return \Psr\Http\Message\ResponseInterface
1600
     */
1601
    public function respond(): ResponseInterface
1602
    {
1603
        $response = get_response();
1604
        $type = '';
1605
1606
        if (self::RESPONSE_TEMPORARILY_DENY === $this->result) {
1607
            $type = 'captcha';
1608
            $statusCode = 403; // Forbidden.
1609
1610
        } elseif (self::RESPONSE_LIMIT_SESSION === $this->result) {
1611
            $type = 'session_limitation';
1612
            $statusCode = 429; // Too Many Requests.
1613
1614
        } elseif (self::RESPONSE_DENY === $this->result) {
1615
            $type = 'rejection';
1616
            $statusCode = 400; // Bad request.
1617
        }
1618
1619
        // Nothing happened. Return.
1620
        if (empty($type)) {
1621
            // @codeCoverageIgnoreStart
1622
            return $response;
1623
            // @codeCoverageIgnoreEnd
1624
        }
1625
1626
        $viewPath = $this->getTemplate($type);
1627
1628
        // The language of output UI. It is used on views.
1629
        $langCode = get_session()->get('shieldon_ui_lang') ?? 'en';
1630
        // Show online session count. It is used on views.
1631
        $showOnlineInformation = true;
1632
        // Show user information such as IP, user-agent, device name.
1633
        $showUserInformation = true;
1634
1635
        if (empty($this->properties['display_online_info'])) {
1636
            $showOnlineInformation = false;
1637
        }
1638
1639
        if (empty($this->properties['display_user_info'])) {
1640
            $showUserInformation = false;
1641
        }
1642
1643
        if ($showUserInformation) {
1644
            $dialoguserinfo['ip'] = $this->ip;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$dialoguserinfo was never initialized. Although not strictly required by PHP, it is generally a good practice to add $dialoguserinfo = array(); before regardless.
Loading history...
1645
            $dialoguserinfo['rdns'] = $this->rdns;
1646
            $dialoguserinfo['user_agent'] = get_request()->getHeaderLine('user-agent');
1647
        }
1648
1649
        $ui = [
1650
            'background_image' => $this->dialogUI['background_image'] ?? '',
1651
            'bg_color'         => $this->dialogUI['bg_color']         ?? '#ffffff',
1652
            'header_bg_color'  => $this->dialogUI['header_bg_color']  ?? '#212531',
1653
            'header_color'     => $this->dialogUI['header_color']     ?? '#ffffff',
1654
            'shadow_opacity'   => $this->dialogUI['shadow_opacity']   ?? '0.2',
1655
        ];
1656
1657
        if (!defined('SHIELDON_VIEW')) {
1658
            define('SHIELDON_VIEW', true);
1659
        }
1660
1661
        $css = require $this->getTemplate('css/default');
1662
1663
        ob_start();
1664
        require $viewPath;
1665
        $output = ob_get_contents();
1666
        ob_end_clean();
1667
1668
        // Remove unused variable notices generated from PHP intelephense.
1669
        unset(
1670
            $css,
1671
            $ui,
1672
            $langCode,
1673
            $showOnlineInformation,
1674
            $showLineupInformation,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $showLineupInformation seems to be never defined.
Loading history...
1675
            $showUserInformation
1676
        );
1677
1678
        $stream = $response->getBody();
1679
        $stream->write($output);
1680
        $stream->rewind();
1681
1682
        return $response->
1683
            withHeader('X-Protected-By', 'shieldon.io')->
1684
            withBody($stream)->
1685
            withStatus($statusCode);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $statusCode does not seem to be defined for all execution paths leading up to this point.
Loading history...
1686
    }
1687
1688
    /**
1689
     * Run, run, run!
1690
     *
1691
     * Check the rule tables first, if an IP address has been listed.
1692
     * Call function filter() if an IP address is not listed in rule tables.
1693
     *
1694
     * @return 
1695
     */
1696
    public function run(): int
1697
    {
1698
        if (!isset($this->registrar[0])) {
1699
            throw new RuntimeException(
1700
                'Must register at least one data driver.'
1701
            );
1702
        }
1703
        
1704
        // Ignore the excluded urls.
1705
        if (!empty($this->excludedUrls)) {
1706
            foreach ($this->excludedUrls as $url) {
1707
                if (0 === strpos(get_request()->getUri()->getPath(), $url)) {
1708
                    return $this->result = self::RESPONSE_ALLOW;
1709
                }
1710
            }
1711
        }
1712
1713
        // Execute closure functions.
1714
        foreach ($this->closures as $closure) {
1715
            $closure();
1716
        }
1717
1718
        $result = $this->process();
1719
1720
        if ($result !== self::RESPONSE_ALLOW) {
1721
1722
            // Current session did not pass the CAPTCHA, it is still stuck in CAPTCHA page.
1723
            $actionCode = self::LOG_CAPTCHA;
1724
1725
            // If current session's respone code is RESPONSE_DENY, record it as `blacklist_count` in our logs.
1726
            // It is stuck in warning page, not CAPTCHA.
1727
            if ($result === self::RESPONSE_DENY) {
1728
                $actionCode = self::LOG_BLACKLIST;
1729
            }
1730
1731
            if ($result === self::RESPONSE_LIMIT_SESSION) {
1732
                $actionCode = self::LOG_LIMIT;
1733
            }
1734
1735
            $this->log($actionCode);
1736
1737
        } else {
1738
1739
            $this->log(self::LOG_PAGEVIEW);
1740
        }
1741
1742
 
1743
        if (!empty($this->msgBody)) {
1744
 
1745
            // @codeCoverageIgnoreStart
1746
1747
            try {
1748
                foreach ($this->messenger as $messenger) {
1749
                    $messenger->setTimeout(2);
1750
                    $messenger->send($this->msgBody);
1751
                }
1752
            } catch (RuntimeException $e) {
1753
                // Do not throw error, becasue the third-party services might be unavailable.
1754
            }
1755
1756
            // @codeCoverageIgnoreEnd
1757
        }
1758
1759
1760
        return $result;
1761
    }
1762
1763
    /**
1764
     * Set the filters.
1765
     *
1766
     * @param array $settings filter settings.
1767
     *
1768
     * @return void
1769
     */
1770
    public function setFilters(array $settings)
1771
    {
1772
        foreach (array_keys($this->filterStatus) as $k) {
1773
            if (isset($settings[$k])) {
1774
                $this->filterStatus[$k] = $settings[$k] ?? false;
1775
            }
1776
        }
1777
    }
1778
1779
    /**
1780
     * Set a filter.
1781
     *
1782
     * @param string $filterName The filter's name.
1783
     * @param bool   $value      True for enabling the filter, overwise.
1784
     *
1785
     * @return void
1786
     */
1787
    public function setFilter(string $filterName, bool $value): void
1788
    {
1789
        if (isset($this->filterStatus[$filterName])) {
1790
            $this->filterStatus[$filterName] = $value;
1791
        }
1792
    }
1793
1794
    /**
1795
     * Get online people count. If enable limitSession.
1796
     *
1797
     * @return int
1798
     */
1799
    public function getSessionCount(): int
1800
    {
1801
        return $this->sessionStatus['count'];
1802
    }
1803
1804
    /**
1805
     * Set the URLs you want them to be excluded them from protection.
1806
     *
1807
     * @param array $urls The list of URL want to be excluded.
1808
     *
1809
     * @return void
1810
     */
1811
    public function setExcludedUrls(array $urls = []): void
1812
    {
1813
        $this->excludedUrls = $urls;
1814
    }
1815
1816
    /**
1817
     * Set a closure function.
1818
     *
1819
     * @param string  $key     The name for the closure class.
1820
     * @param Closure $closure An instance will be later called.
1821
     *
1822
     * @return void
1823
     */
1824
    public function setClosure(string $key, Closure $closure): void
1825
    {
1826
        $this->closures[$key] = $closure;
1827
    }
1828
1829
    /**
1830
     * Print a JavasSript snippet in your webpages.
1831
     * 
1832
     * This snippet generate cookie on client's browser,then we check the 
1833
     * cookie to identify the client is a rebot or not.
1834
     *
1835
     * @return string
1836
     */
1837
    public function outputJsSnippet(): string
1838
    {
1839
        $tmpCookieName = $this->properties['cookie_name'];
1840
        $tmpCookieDomain = $this->properties['cookie_domain'];
1841
1842
        if (empty($tmpCookieDomain) && get_request()->getHeaderLine('host')) {
1843
            $tmpCookieDomain = get_request()->getHeaderLine('host');
1844
        }
1845
1846
        $tmpCookieValue = $this->properties['cookie_value'];
1847
1848
        $jsString = '
1849
            <script>
1850
                var d = new Date();
1851
                d.setTime(d.getTime()+(60*60*24*30));
1852
                document.cookie = "' . $tmpCookieName . '=' . $tmpCookieValue . ';domain=.' . $tmpCookieDomain . ';expires="+d.toUTCString();
1853
            </script>
1854
        ';
1855
1856
        return $jsString;
1857
    }
1858
1859
    /**
1860
     * Get current visior's path.
1861
     *
1862
     * @return string
1863
     */
1864
    public function getCurrentUrl(): string
1865
    {
1866
        return get_request()->getUri()->getPath();
1867
    }
1868
1869
    /**
1870
     * Displayed on Firewall Panel, tell you current what type of current
1871
     * configuration is used for.
1872
     * 
1873
     * @param string $type The type of configuration.
1874
     *                     demo | managed | config
1875
     *
1876
     * @return void
1877
     */
1878
    public function managedBy(string $type = ''): void
1879
    {
1880
        if (in_array($type, ['managed', 'config', 'demo'])) {
1881
            $this->firewallType = $type;
1882
        }
1883
    }
1884
}
1885