Passed
Push — 2.x ( 255d06...9f06f6 )
by Terry
02:06
created

Kernel::isFakeRobot()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 3
nop 0
dl 0
loc 13
rs 10
c 0
b 0
f 0
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
     * Result.
278
     *
279
     * @var int
280
     */
281
    protected $result = 1;
282
283
    /**
284
     * URLs that are excluded from Shieldon's protection.
285
     *
286
     * @var array
287
     */
288
    protected $excludedUrls = [];
289
290
    /**
291
     * Which type of configuration source that Shieldon firewall managed?
292
     *
293
     * @var string
294
     */
295
    protected $firewallType = 'self'; // managed | config | self | demo
296
297
    /**
298
     * Custom dialog UI settings.
299
     *
300
     * @var array
301
     */
302
    protected $dialogUI = [];
303
304
    /**
305
     * Store the class information used in Shieldon.
306
     *
307
     * @var array
308
     */
309
    protected $registrar = [];
310
311
    /**
312
     * Strict mode.
313
     * 
314
     * Set by `strictMode()` only. The default value of this propertry is undefined.
315
     *
316
     * @var bool
317
     */
318
    protected $strictMode;
319
320
    /**
321
     * The directory in where the frontend template files are placed.
322
     *
323
     * @var string
324
     */
325
    protected $templateDirectory = '';
326
327
    /**
328
     * The message that will be sent to the third-party API.
329
     *
330
     * @var string
331
     */
332
    protected $msgBody = '';
333
334
    /**
335
     * Shieldon constructor.
336
     * 
337
     * @param ServerRequestInterface|null $request  A PSR-7 server request.
338
     * 
339
     * @return void
340
     */
341
    public function __construct(?ServerRequestInterface $request  = null, ?ResponseInterface $response = null)
342
    {
343
        // Load helper functions. This is the must.
344
        new Helpers();
345
346
        if (is_null($request)) {
347
            $request = HttpFactory::createRequest();
348
        }
349
350
        if (is_null($response)) {
351
            $response = HttpFactory::createResponse();
352
        }
353
354
        $session = HttpFactory::createSession();
355
356
        $this->properties = get_default_properties();
357
        $this->add(new Foundation());
358
359
        Container::set('request', $request);
360
        Container::set('response', $response);
361
        Container::set('session', $session);
362
        Container::set('shieldon', $this);
363
    }
364
365
    /**
366
     * Log actions.
367
     *
368
     * @param int $actionCode The code number of the action.
369
     *
370
     * @return void
371
     */
372
    protected function log(int $actionCode): void
373
    {
374
        if (null !== $this->logger) {
375
            $logData = [];
376
            $logData['ip'] = $this->getIp();
377
            $logData['session_id'] = get_session()->get('id');
378
            $logData['action_code'] = $actionCode;
379
            $logData['timesamp'] = time();
380
    
381
            $this->logger->add($logData);
382
        }
383
    }
384
385
    /**
386
     * Initialize components.
387
     *
388
     * @return void
389
     */
390
    private function initComponents()
391
    {
392
        foreach (array_keys($this->component) as $name) {
393
            $this->component[$name]->setIp($this->ip);
394
            $this->component[$name]->setRdns($this->rdns);
395
396
            // Apply global strict mode to all components by `strictMode()` if nesscessary.
397
            if (isset($this->strictMode)) {
398
                $this->component[$name]->setStrict($this->strictMode);
399
            }
400
        }
401
    }
402
403
    /**
404
     * Look up the rule table.
405
     *
406
     * If a specific IP address doesn't exist, return false. 
407
     * Otherwise, return true.
408
     *
409
     * @return bool
410
     */
411
    private function isRuleTable()
412
    {
413
        $ipRule = $this->driver->get($this->ip, 'rule');
414
415
        if (empty($ipRule)) {
416
            return false;
417
        }
418
419
        $ruleType = (int) $ipRule['type'];
420
421
        // Apply the status code.
422
        $this->result = $ruleType;
423
424
        if ($ruleType === self::ACTION_ALLOW) {
425
            return true;
426
        }
427
428
        // Current visitor has been blocked. If he still attempts accessing the site, 
429
        // then we can drop him into the permanent block list.
430
        $attempts = $ipRule['attempts'] ?? 0;
431
        $now = time();
432
        $logData = [];
433
434
        $logData['log_ip']     = $ipRule['log_ip'];
435
        $logData['ip_resolve'] = $ipRule['ip_resolve'];
436
        $logData['time']       = $now;
437
        $logData['type']       = $ipRule['type'];
438
        $logData['reason']     = $ipRule['reason'];
439
        $logData['attempts']   = $attempts;
440
441
        // @since 0.2.0
442
        $attemptPeriod = $this->properties['record_attempt_detection_period'];
443
        $attemptReset  = $this->properties['reset_attempt_counter'];
444
445
        $lastTimeDiff = $now - $ipRule['time'];
446
447
        if ($lastTimeDiff <= $attemptPeriod) {
448
            $logData['attempts'] = ++$attempts;
449
        }
450
451
        if ($lastTimeDiff > $attemptReset) {
452
            $logData['attempts'] = 0;
453
        }
454
455
        $isMessengerTriggered = false;
456
        $isUpdatRuleTable = false;
457
458
        $handleType = 0;
459
460
        if ($this->properties['deny_attempt_enable']['data_circle']) {
461
462
            if ($ruleType === self::ACTION_TEMPORARILY_DENY) {
463
464
                $isUpdatRuleTable = true;
465
466
                $buffer = $this->properties['deny_attempt_buffer']['data_circle'];
467
468
                if ($attempts >= $buffer) {
469
470
                    if ($this->properties['deny_attempt_notify']['data_circle']) {
471
                        $isMessengerTriggered = true;
472
                    }
473
474
                    $logData['type'] = self::ACTION_DENY;
475
476
                    // Reset this value for next checking process - iptables.
477
                    $logData['attempts'] = 0;
478
                    $handleType = 1;
479
                }
480
            }
481
        }
482
483
        if ($this->properties['deny_attempt_enable']['system_firewall']) {
484
            
485
            if ($ruleType === self::ACTION_DENY) {
486
487
                $isUpdatRuleTable = true;
488
489
                // For the requests that are already banned, but they are still attempting access, that means 
490
                // that they are programmably accessing your website. Consider put them in the system-layer fireall
491
                // such as IPTABLE.
492
                $bufferIptable = $this->properties['deny_attempt_buffer']['system_firewall'];
493
494
                if ($attempts >= $bufferIptable) {
495
496
                    if ($this->properties['deny_attempt_notify']['system_firewall']) {
497
                        $isMessengerTriggered = true;
498
                    }
499
500
                    $folder = rtrim($this->properties['iptables_watching_folder'], '/');
501
502
                    if (file_exists($folder) && is_writable($folder)) {
503
                        $filePath = $folder . '/iptables_queue.log';
504
505
                        // command, ipv4/6, ip, subnet, port, protocol, action
506
                        // add,4,127.0.0.1,null,all,all,drop  (example)
507
                        // add,4,127.0.0.1,null,80,tcp,drop   (example)
508
                        $command = 'add,4,' . $this->ip . ',null,all,all,deny';
509
510
                        if (filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
511
                            $command = 'add,6,' . $this->ip . ',null,all,allow';
512
                        }
513
514
                        // Add this IP address to itables_queue.log
515
                        // Use `bin/iptables.sh` for adding it into IPTABLES. See document for more information. 
516
                        file_put_contents($filePath, $command . "\n", FILE_APPEND | LOCK_EX);
517
518
                        $logData['attempts'] = 0;
519
                        $handleType = 2;
520
                    }
521
                }
522
            }
523
        }
524
525
        // We only update data when `deny_attempt_enable` is enable.
526
        // Because we want to get the last visited time and attempt counter.
527
        // Otherwise we don't update it everytime to avoid wasting CPU resource.
528
        if ($isUpdatRuleTable) {
529
            $this->driver->save($this->ip, $logData, 'rule');
530
        }
531
532
        /**
533
         * Notify this event to messenger.
534
         */
535
        if ($isMessengerTriggered) {
536
537
            // The data strings that will be appended to message body.
538
            $prepareMessageData = [
539
                __('core', 'messenger_text_ip')       => $logData['log_ip'],
540
                __('core', 'messenger_text_rdns')     => $logData['ip_resolve'],
541
                __('core', 'messenger_text_reason')   => __('core', 'messenger_text_reason_code_' . $logData['reason']),
542
                __('core', 'messenger_text_handle')   => __('core', 'messenger_text_handle_type_' . $handleType),
543
                __('core', 'messenger_text_system')   => '',
544
                __('core', 'messenger_text_cpu')      => get_cpu_usage(),
545
                __('core', 'messenger_text_memory')   => get_memory_usage(),
546
                __('core', 'messenger_text_time')     => date('Y-m-d H:i:s', $logData['time']),
547
                __('core', 'messenger_text_timezone') => date_default_timezone_get(),
548
            ];
549
550
            $message = __('core', 'messenger_notification_subject', 'Notification for {0}', [$this->ip]) . "\n\n";
551
552
            foreach ($prepareMessageData as $key => $value) {
553
                $message .= $key . ': ' . $value . "\n";
554
            }
555
556
            $this->msgBody = $message;
557
        }
558
559
        return true;
560
    }
561
562
    private function isTrustedBot()
563
    {
564
        if ($this->getComponent('TrustedBot')) {
565
566
            // We want to put all the allowed robot into the rule list, so that the checking of IP's resolved hostname 
567
            // is no more needed for that IP.
568
            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

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

570
                if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isGoogle()) {
Loading history...
571
                    // Add current IP into allowed list, because it is from real Google domain.
572
                    $this->action(
573
                        self::ACTION_ALLOW,
574
                        self::REASON_IS_GOOGLE
575
                    );
576
577
                } 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

577
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isBing()) {
Loading history...
578
                    // Add current IP into allowed list, because it is from real Bing domain.
579
                    $this->action(
580
                        self::ACTION_ALLOW,
581
                        self::REASON_IS_BING
582
                    );
583
584
                } 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

584
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isYahoo()) {
Loading history...
585
                    // Add current IP into allowed list, because it is from real Yahoo domain.
586
                    $this->action(
587
                        self::ACTION_ALLOW,
588
                        self::REASON_IS_YAHOO
589
                    );
590
591
                } else {
592
                    // Add current IP into allowed list, because you trust it.
593
                    // You have already defined it in the settings.
594
                    $this->action(
595
                        self::ACTION_ALLOW,
596
                        self::REASON_IS_SEARCH_ENGINE
597
                    );
598
                }
599
                // Allowed robots not join to our traffic handler.
600
                $this->result = self::RESPONSE_ALLOW;
601
                return true;
602
            }
603
        }
604
        return false;
605
    }
606
607
    /**
608
     * Check whether the IP is fake search engine or not.
609
     * The method "isTrustedBot()" must be executed before this method.
610
     *
611
     * @return bool
612
     */
613
    private function isFakeRobot(): bool
614
    {
615
        if ($this->getComponent('TrustedBot')) {
616
            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

616
            if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isFakeRobot()) {
Loading history...
617
                $this->action(
618
                    self::ACTION_DENY,
619
                    self::REASON_COMPONENT_TRUSTED_ROBOT
620
                );
621
                $this->result = self::RESPONSE_DENY;
622
                return true;
623
            }
624
        }
625
        return false;
626
    }
627
628
    /**
629
     * Run, run, run!
630
     *
631
     * Check the rule tables first, if an IP address has been listed.
632
     * Call function filter() if an IP address is not listed in rule tables.
633
     *
634
     * @return int The response code.
635
     */
636
    protected function process(): int
637
    {
638
        $this->driver->init($this->autoCreateDatabase);
639
640
        $this->initComponents();
641
642
        /*
643
        |--------------------------------------------------------------------------
644
        | Stage - Looking for rule table.
645
        |--------------------------------------------------------------------------
646
        */
647
648
        if ($this->isRuleTable()) {
649
            return $this->result;
650
        }
651
652
        /*
653
        |--------------------------------------------------------------------------
654
        | Statge - Detect popular search engine.
655
        |--------------------------------------------------------------------------
656
        */
657
658
        if ($this->isTrustedBot()) {
659
            return $this->result;
660
        }
661
662
        if ($this->isFakeRobot()) {
663
            return $this->result;
664
        }
665
        
666
        /*
667
        |--------------------------------------------------------------------------
668
        | Stage - IP component.
669
        |--------------------------------------------------------------------------
670
        */
671
672
        if ($this->getComponent('Ip')) {
673
674
            $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

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