Passed
Push — 2.x ( 9f06f6...92ec38 )
by Terry
01:59
created

Kernel::isRuleTable()   B

Complexity

Conditions 9
Paths 66

Size

Total Lines 70
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 37
c 2
b 0
f 0
nc 66
nop 0
dl 0
loc 70
rs 7.7724

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
     * The status for Filters to reset.
201
     *
202
     * @var array
203
     */
204
    protected $filterResetStatus = [
205
        's' => false, // second.
206
        'm' => false, // minute.
207
        'h' => false, // hour.
208
        'd' => false, // day.
209
    ];
210
211
    /**
212
     * default settings
213
     *
214
     * @var array
215
     */
216
    protected $properties = [];
217
218
    /**
219
     * This is for creating data tables automatically
220
     * Turn it off, if you don't want to check data tables every connection.
221
     *
222
     * @var bool
223
     */
224
    protected $autoCreateDatabase = true;
225
226
    /**
227
     * Container for captcha addons.
228
     * The collection of \Shieldon\Firewall\Captcha\CaptchaInterface
229
     *
230
     * @var array
231
     */
232
    protected $captcha = [];
233
234
    /**
235
     * The ways Shieldon send a message to when someone has been blocked.
236
     * The collection of \Shieldon\Messenger\Messenger\MessengerInterface
237
     *
238
     * @var array
239
     */
240
    protected $messenger = [];
241
242
    /**
243
     * Is to limit traffic?
244
     *
245
     * @var array
246
     */
247
    protected $sessionLimit = [
248
249
        // How many sessions will be available?
250
        // 0 = no limit.
251
        'count' => 0,
252
253
        // How many minutes will a session be availe to visit?
254
        // 0 = no limit.
255
        'period' => 0, 
256
    ];
257
258
    /**
259
     * Record the online session status.
260
     * This will be enabled when $sessionLimit[count] > 0
261
     *
262
     * @var array
263
     */
264
    protected $sessionStatus = [
265
266
        // Online session count.
267
        'count' => 0,
268
269
        // Current session order.
270
        'order' => 0,
271
272
        // Current waiting queue.
273
        'queue' => 0,
274
    ];
275
276
    /**
277
     * The events.
278
     *
279
     * @var array
280
     */
281
    protected $event = [
282
283
        // Update rule table when this value true.
284
        'update_rule_table' => false,
285
286
        // Send notifications when this value true.
287
        'trigger_messengers' => false,
288
    ];
289
290
    /**
291
     * Result.
292
     *
293
     * @var int
294
     */
295
    protected $result = 1;
296
297
    /**
298
     * URLs that are excluded from Shieldon's protection.
299
     *
300
     * @var array
301
     */
302
    protected $excludedUrls = [];
303
304
    /**
305
     * Which type of configuration source that Shieldon firewall managed?
306
     *
307
     * @var string
308
     */
309
    protected $firewallType = 'self'; // managed | config | self | demo
310
311
    /**
312
     * Custom dialog UI settings.
313
     *
314
     * @var array
315
     */
316
    protected $dialogUI = [];
317
318
    /**
319
     * Store the class information used in Shieldon.
320
     *
321
     * @var array
322
     */
323
    protected $registrar = [];
324
325
    /**
326
     * Strict mode.
327
     * 
328
     * Set by `strictMode()` only. The default value of this propertry is undefined.
329
     *
330
     * @var bool
331
     */
332
    protected $strictMode;
333
334
    /**
335
     * The directory in where the frontend template files are placed.
336
     *
337
     * @var string
338
     */
339
    protected $templateDirectory = '';
340
341
    /**
342
     * The message that will be sent to the third-party API.
343
     *
344
     * @var string
345
     */
346
    protected $msgBody = '';
347
348
    /**
349
     * Shieldon constructor.
350
     * 
351
     * @param ServerRequestInterface|null $request  A PSR-7 server request.
352
     * 
353
     * @return void
354
     */
355
    public function __construct(?ServerRequestInterface $request  = null, ?ResponseInterface $response = null)
356
    {
357
        // Load helper functions. This is the must.
358
        new Helpers();
359
360
        if (is_null($request)) {
361
            $request = HttpFactory::createRequest();
362
        }
363
364
        if (is_null($response)) {
365
            $response = HttpFactory::createResponse();
366
        }
367
368
        $session = HttpFactory::createSession();
369
370
        $this->properties = get_default_properties();
371
        $this->add(new Foundation());
372
373
        Container::set('request', $request);
374
        Container::set('response', $response);
375
        Container::set('session', $session);
376
        Container::set('shieldon', $this);
377
    }
378
379
    /**
380
     * Log actions.
381
     *
382
     * @param int $actionCode The code number of the action.
383
     *
384
     * @return void
385
     */
386
    protected function log(int $actionCode): void
387
    {
388
        if (null !== $this->logger) {
389
            $logData = [];
390
            $logData['ip'] = $this->getIp();
391
            $logData['session_id'] = get_session()->get('id');
392
            $logData['action_code'] = $actionCode;
393
            $logData['timesamp'] = time();
394
    
395
            $this->logger->add($logData);
396
        }
397
    }
398
399
    /**
400
     * Initialize components.
401
     *
402
     * @return void
403
     */
404
    private function initComponents()
405
    {
406
        foreach (array_keys($this->component) as $name) {
407
            $this->component[$name]->setIp($this->ip);
408
            $this->component[$name]->setRdns($this->rdns);
409
410
            // Apply global strict mode to all components by `strictMode()` if nesscessary.
411
            if (isset($this->strictMode)) {
412
                $this->component[$name]->setStrict($this->strictMode);
413
            }
414
        }
415
    }
416
417
    /**
418
     * Look up the rule table.
419
     *
420
     * If a specific IP address doesn't exist, return false. 
421
     * Otherwise, return true.
422
     *
423
     * @return bool
424
     */
425
    private function isRuleTable()
426
    {
427
        $ipRule = $this->driver->get($this->ip, 'rule');
428
429
        if (empty($ipRule)) {
430
            return false;
431
        }
432
433
        $ruleType = (int) $ipRule['type'];
434
435
        // Apply the status code.
436
        $this->result = $ruleType;
437
438
        if ($ruleType === self::ACTION_ALLOW) {
439
            return true;
440
        }
441
442
        // Current visitor has been blocked. If he still attempts accessing the site, 
443
        // then we can drop him into the permanent block list.
444
        $attempts = $ipRule['attempts'] ?? 0;
445
        $now = time();
446
        $logData = [];
447
        $handleType = 0;
448
449
        $logData['log_ip']     = $ipRule['log_ip'];
450
        $logData['ip_resolve'] = $ipRule['ip_resolve'];
451
        $logData['time']       = $now;
452
        $logData['type']       = $ipRule['type'];
453
        $logData['reason']     = $ipRule['reason'];
454
        $logData['attempts']   = $attempts;
455
456
        // @since 0.2.0
457
        $attemptPeriod = $this->properties['record_attempt_detection_period'];
458
        $attemptReset  = $this->properties['reset_attempt_counter'];
459
460
        $lastTimeDiff = $now - $ipRule['time'];
461
462
        if ($lastTimeDiff <= $attemptPeriod) {
463
            $logData['attempts'] = ++$attempts;
464
        }
465
466
        if ($lastTimeDiff > $attemptReset) {
467
            $logData['attempts'] = 0;
468
        }
469
470
        if ($ruleType === self::ACTION_TEMPORARILY_DENY) {
471
            $ratd = $this->determineAttemptsTemporaryDeny($logData, $handleType, $attempts);
472
            $logData = $ratd['log_data'];
473
            $handleType = $ratd['handle_type'];
474
        }
475
476
        if ($ruleType === self::ACTION_DENY) {
477
            $rapd = $this->determineAttemptsPermanentDeny($logData, $handleType, $attempts);
478
            $logData = $rapd['log_data'];
479
            $handleType = $rapd['handle_type'];
480
        }
481
482
        // We only update data when `deny_attempt_enable` is enable.
483
        // Because we want to get the last visited time and attempt counter.
484
        // Otherwise, we don't update it everytime to avoid wasting CPU resource.
485
        if ($this->event['update_rule_table']) {
486
            $this->driver->save($this->ip, $logData, 'rule');
487
        }
488
489
        // Notify this event to messenger.
490
        if ($this->event['trigger_messengers']) {
491
            $this->prepareMessengerBody($logData, $handleType);
492
        }
493
494
        return true;
495
    }
496
497
    /**
498
     * Record the attempts when the user is temporarily denied by rule table.
499
     *
500
     * @param array $logData
501
     * @param int   $handleType
502
     * @param int   $attempts
503
     * 
504
     * @return array
505
     */
506
    private function determineAttemptsTemporaryDeny(array $logData, int $handleType, int $attempts): array
507
    {
508
        if ($this->properties['deny_attempt_enable']['data_circle']) {
509
            $this->event['update_rule_table'] = true;
510
511
            $buffer = $this->properties['deny_attempt_buffer']['data_circle'];
512
513
            if ($attempts >= $buffer) {
514
515
                if ($this->properties['deny_attempt_notify']['data_circle']) {
516
                    $this->event['trigger_messengers'] = true;
517
                }
518
519
                $logData['type'] = self::ACTION_DENY;
520
521
                // Reset this value for next checking process - iptables.
522
                $logData['attempts'] = 0;
523
                $handleType = 1;
524
            }
525
        }
526
527
        return [
528
            'log_data' => $logData,
529
            'handle_type' => $handleType,
530
        ];
531
    }
532
533
    /**
534
     * Record the attempts when the user is permanently denied by rule table.
535
     *
536
     * @param array $logData
537
     * @param int   $handleType
538
     * @param int   $attempts
539
     * 
540
     * @return array
541
     */
542
    private function determineAttemptsPermanentDeny(array $logData, int $handleType, int $attempts): array
543
    {
544
        if ($this->properties['deny_attempt_enable']['system_firewall']) {
545
            $this->event['update_rule_table'] = true;
546
547
            // For the requests that are already banned, but they are still attempting access, that means 
548
            // that they are programmably accessing your website. Consider put them in the system-layer fireall
549
            // such as IPTABLE.
550
            $bufferIptable = $this->properties['deny_attempt_buffer']['system_firewall'];
551
552
            if ($attempts >= $bufferIptable) {
553
554
                if ($this->properties['deny_attempt_notify']['system_firewall']) {
555
                    $this->event['trigger_messengers'] = true;
556
                }
557
558
                $folder = rtrim($this->properties['iptables_watching_folder'], '/');
559
560
                if (file_exists($folder) && is_writable($folder)) {
561
                    $filePath = $folder . '/iptables_queue.log';
562
563
                    // command, ipv4/6, ip, subnet, port, protocol, action
564
                    // add,4,127.0.0.1,null,all,all,drop  (example)
565
                    // add,4,127.0.0.1,null,80,tcp,drop   (example)
566
                    $command = 'add,4,' . $this->ip . ',null,all,all,deny';
567
568
                    if (filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
569
                        $command = 'add,6,' . $this->ip . ',null,all,allow';
570
                    }
571
572
                    // Add this IP address to itables_queue.log
573
                    // Use `bin/iptables.sh` for adding it into IPTABLES. See document for more information. 
574
                    file_put_contents($filePath, $command . "\n", FILE_APPEND | LOCK_EX);
575
576
                    $logData['attempts'] = 0;
577
                    $handleType = 2;
578
                }
579
            }
580
        }
581
582
        return [
583
            'log_data' => $logData,
584
            'handle_type' => $handleType,
585
        ];
586
    }
587
588
    /**
589
     * Prepare the message body for messenger modules to sent.
590
     *
591
     * @param array $logData
592
     * @param int   $handleType
593
     * 
594
     * @return void
595
     */
596
    private function prepareMessengerBody(array $logData, int $handleType): void
597
    {
598
        // The data strings that will be appended to message body.
599
        $prepareMessageData = [
600
            __('core', 'messenger_text_ip')       => $logData['log_ip'],
601
            __('core', 'messenger_text_rdns')     => $logData['ip_resolve'],
602
            __('core', 'messenger_text_reason')   => __('core', 'messenger_text_reason_code_' . $logData['reason']),
603
            __('core', 'messenger_text_handle')   => __('core', 'messenger_text_handle_type_' . $handleType),
604
            __('core', 'messenger_text_system')   => '',
605
            __('core', 'messenger_text_cpu')      => get_cpu_usage(),
606
            __('core', 'messenger_text_memory')   => get_memory_usage(),
607
            __('core', 'messenger_text_time')     => date('Y-m-d H:i:s', $logData['time']),
608
            __('core', 'messenger_text_timezone') => date_default_timezone_get(),
609
        ];
610
611
        $message = __('core', 'messenger_notification_subject', 'Notification for {0}', [$this->ip]) . "\n\n";
612
613
        foreach ($prepareMessageData as $key => $value) {
614
            $message .= $key . ': ' . $value . "\n";
615
        }
616
617
        $this->msgBody = $message;
618
    }
619
620
    /**
621
     * Check if current IP is trusted or not.
622
     *
623
     * @return bool
624
     */
625
    private function isTrustedBot()
626
    {
627
        if ($this->getComponent('TrustedBot')) {
628
629
            // We want to put all the allowed robot into the rule list, so that the checking of IP's resolved hostname 
630
            // is no more needed for that IP.
631
            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

631
            if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isAllowed()) {
Loading history...
632
633
                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

633
                if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isGoogle()) {
Loading history...
634
                    // Add current IP into allowed list, because it is from real Google domain.
635
                    $this->action(
636
                        self::ACTION_ALLOW,
637
                        self::REASON_IS_GOOGLE
638
                    );
639
640
                } 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

640
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isBing()) {
Loading history...
641
                    // Add current IP into allowed list, because it is from real Bing domain.
642
                    $this->action(
643
                        self::ACTION_ALLOW,
644
                        self::REASON_IS_BING
645
                    );
646
647
                } 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

647
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isYahoo()) {
Loading history...
648
                    // Add current IP into allowed list, because it is from real Yahoo domain.
649
                    $this->action(
650
                        self::ACTION_ALLOW,
651
                        self::REASON_IS_YAHOO
652
                    );
653
654
                } else {
655
                    // Add current IP into allowed list, because you trust it.
656
                    // You have already defined it in the settings.
657
                    $this->action(
658
                        self::ACTION_ALLOW,
659
                        self::REASON_IS_SEARCH_ENGINE
660
                    );
661
                }
662
                // Allowed robots not join to our traffic handler.
663
                $this->result = self::RESPONSE_ALLOW;
664
                return true;
665
            }
666
        }
667
        return false;
668
    }
669
670
    /**
671
     * Check whether the IP is fake search engine or not.
672
     * The method "isTrustedBot()" must be executed before this method.
673
     *
674
     * @return bool
675
     */
676
    private function isFakeRobot(): bool
677
    {
678
        if ($this->getComponent('TrustedBot')) {
679
            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

679
            if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isFakeRobot()) {
Loading history...
680
                $this->action(
681
                    self::ACTION_DENY,
682
                    self::REASON_COMPONENT_TRUSTED_ROBOT
683
                );
684
                $this->result = self::RESPONSE_DENY;
685
                return true;
686
            }
687
        }
688
        return false;
689
    }
690
691
    /**
692
     * Run, run, run!
693
     *
694
     * Check the rule tables first, if an IP address has been listed.
695
     * Call function filter() if an IP address is not listed in rule tables.
696
     *
697
     * @return int The response code.
698
     */
699
    protected function process(): int
700
    {
701
        $this->driver->init($this->autoCreateDatabase);
702
703
        $this->initComponents();
704
705
        /*
706
        |--------------------------------------------------------------------------
707
        | Stage - Looking for rule table.
708
        |--------------------------------------------------------------------------
709
        */
710
711
        if ($this->isRuleTable()) {
712
            return $this->result;
713
        }
714
715
        /*
716
        |--------------------------------------------------------------------------
717
        | Statge - Detect popular search engine.
718
        |--------------------------------------------------------------------------
719
        */
720
721
        if ($this->isTrustedBot()) {
722
            return $this->result;
723
        }
724
725
        if ($this->isFakeRobot()) {
726
            return $this->result;
727
        }
728
        
729
        /*
730
        |--------------------------------------------------------------------------
731
        | Stage - IP component.
732
        |--------------------------------------------------------------------------
733
        */
734
735
        if ($this->getComponent('Ip')) {
736
737
            $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

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