Passed
Push — 2.x ( 44da33...f0bfd9 )
by Terry
01:53
created

Kernel::setExcludedUrls()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
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\ResponseInterface;
35
use Psr\Http\Message\ServerRequestInterface;
36
use Shieldon\Firewall\Captcha\Foundation;
37
use Shieldon\Firewall\Helpers;
38
use Shieldon\Firewall\HttpFactory;
39
use Shieldon\Firewall\IpTrait;
40
use Shieldon\Firewall\Kernel\CaptchaTrait;
41
use Shieldon\Firewall\Kernel\ComponentTrait;
42
use Shieldon\Firewall\Kernel\DriverTrait;
43
use Shieldon\Firewall\Kernel\FilterTrait;
44
use Shieldon\Firewall\Kernel\MessengerTrait;
45
use Shieldon\Firewall\Kernel\RuleTrait;
46
use Shieldon\Firewall\Kernel\SessionTrait;
47
use Shieldon\Firewall\Log\ActionLogger;
48
use Shieldon\Firewall\Utils\Container;
49
use function Shieldon\Firewall\get_default_properties;
50
use function Shieldon\Firewall\get_request;
51
use function Shieldon\Firewall\get_response;
52
use function Shieldon\Firewall\get_session;
53
54
use Closure;
55
use InvalidArgumentException;
56
use RuntimeException;
57
use function file_exists;
58
use function get_class;
59
use function gethostbyaddr;
60
use function is_dir;
61
use function ob_end_clean;
62
use function ob_get_contents;
63
use function ob_start;
64
use function strpos;
65
use function strrpos;
66
use function substr;
67
use function time;
68
69
/**
70
 * The primary Shiendon class.
71
 */
72
class Kernel
73
{
74
    use CaptchaTrait;
75
    use ComponentTrait;
76
    use DriverTrait;
77
    use FilterTrait;
78
    use IpTrait;
79
    use MessengerTrait;
80
    use RuleTrait;
81
    use SessionTrait;
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
     * The result passed from filters, compoents, etc.
136
     * 
137
     * DENY    : 0
138
     * ALLOW   : 1
139
     * CAPTCHA : 2
140
     *
141
     * @var int
142
     */
143
    protected $result = 1;
144
145
    /**
146
     * default settings
147
     *
148
     * @var array
149
     */
150
    protected $properties = [];
151
152
    /**
153
     * Logger instance.
154
     *
155
     * @var ActionLogger
156
     */
157
    public $logger;
158
159
    /**
160
     * The closure functions that will be executed in this->run()
161
     *
162
     * @var array
163
     */
164
    protected $closures = [];
165
166
    /**
167
     * URLs that are excluded from Shieldon's protection.
168
     *
169
     * @var array
170
     */
171
    protected $excludedUrls = [];
172
173
    /**
174
     * Custom dialog UI settings.
175
     *
176
     * @var array
177
     */
178
    protected $dialogUI = [];
179
180
    /**
181
     * Strict mode.
182
     * 
183
     * Set by `strictMode()` only. The default value of this propertry is undefined.
184
     *
185
     * @var bool|null
186
     */
187
    protected $strictMode;
188
189
    /**
190
     * The directory in where the frontend template files are placed.
191
     *
192
     * @var string
193
     */
194
    protected $templateDirectory = '';
195
196
    /**
197
     * Which type of configuration source that Shieldon firewall managed?
198
     * value: managed | config | self | demo
199
     *
200
     * @var string
201
     */
202
    protected $firewallType = 'self'; 
203
204
    /**
205
     * Shieldon constructor.
206
     * 
207
     * @param ServerRequestInterface|null $request  A PSR-7 server request.
208
     * 
209
     * @return void
210
     */
211
    public function __construct(?ServerRequestInterface $request  = null, ?ResponseInterface $response = null)
212
    {
213
        // Load helper functions. This is the must.
214
        new Helpers();
215
216
        $request = $request ?? HttpFactory::createRequest();
217
        $response = $response ?? HttpFactory::createResponse();
218
        $session = HttpFactory::createSession();
219
220
        $this->properties = get_default_properties();
221
        $this->setCaptcha(new Foundation());
222
223
        Container::set('request', $request);
224
        Container::set('response', $response);
225
        Container::set('session', $session);
226
        Container::set('shieldon', $this);
227
    }
228
229
    /**
230
     * Run, run, run!
231
     *
232
     * Check the rule tables first, if an IP address has been listed.
233
     * Call function filter() if an IP address is not listed in rule tables.
234
     *
235
     * @return 
236
     */
237
    public function run(): int
238
    {
239
        $this->assertDriver();
240
241
        // Ignore the excluded urls.
242
        foreach ($this->excludedUrls as $url) {
243
            if (strpos($this->getCurrentUrl(), $url) === 0) {
244
                return $this->result = self::RESPONSE_ALLOW;
245
            }
246
        }
247
248
        // Execute closure functions.
249
        foreach ($this->closures as $closure) {
250
            $closure();
251
        }
252
253
        $result = $this->process();
254
255
        if ($result !== self::RESPONSE_ALLOW) {
256
257
            // Current session did not pass the CAPTCHA, it is still stuck in CAPTCHA page.
258
            $actionCode = self::LOG_CAPTCHA;
259
260
            // If current session's respone code is RESPONSE_DENY, record it as `blacklist_count` in our logs.
261
            // It is stuck in warning page, not CAPTCHA.
262
            if ($result === self::RESPONSE_DENY) {
263
                $actionCode = self::LOG_BLACKLIST;
264
            }
265
266
            if ($result === self::RESPONSE_LIMIT_SESSION) {
267
                $actionCode = self::LOG_LIMIT;
268
            }
269
270
            $this->log($actionCode);
271
272
        } else {
273
274
            $this->log(self::LOG_PAGEVIEW);
275
        }
276
277
        // @ MessengerTrait
278
        $this->triggerMessengers();
279
280
        return $result;
281
    }
282
283
    /**
284
     * Respond the result.
285
     *
286
     * @return ResponseInterface
287
     */
288
    public function respond(): ResponseInterface
289
    {
290
        $response = get_response();
291
        $type = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $type is dead and can be removed.
Loading history...
292
293
        $httpStatusCodes = [
294
            self::RESPONSE_TEMPORARILY_DENY => [
295
                'type' => 'captcha',
296
                'code' => 403, // Forbidden.
297
            ],
298
299
            self::RESPONSE_LIMIT_SESSION => [
300
                'type' => 'session_limitation',
301
                'code' => 429, // Too Many Requests.
302
            ],
303
304
            self::RESPONSE_DENY => [
305
                'type' => 'rejection',
306
                'code' => 400, // Bad request.
307
            ],
308
        ];
309
310
        // Nothing happened. Return.
311
        if (empty($httpStatusCodes[$this->result])) {
312
            return $response;
313
        }
314
315
        $type = $httpStatusCodes[$this->result]['type'];
316
        $statusCode = $httpStatusCodes[$this->result]['code'];
317
318
        $viewPath = $this->getTemplate($type);
319
320
        // The language of output UI. It is used on views.
321
        $langCode = get_session()->get('shieldon_ui_lang') ?? 'en';
322
323
        $showOnlineInformation = false;
324
        $showUserInformation = false;
325
        
326
        // Show online session count. It is used on views.
327
        if (!empty($this->properties['display_online_info'])) {
328
            $showOnlineInformation = true;
329
            $onlineinfo['queue'] = $this->sessionStatus['queue'];
0 ignored issues
show
Comprehensibility Best Practice introduced by
$onlineinfo was never initialized. Although not strictly required by PHP, it is generally a good practice to add $onlineinfo = array(); before regardless.
Loading history...
330
            $onlineinfo['count'] = $this->sessionStatus['count'];
331
            $onlineinfo['period'] = $this->sessionLimit['period'];
332
        } 
333
334
        // Show user information such as IP, user-agent, device name.
335
        if (!empty($this->properties['display_user_info'])) {
336
            $showUserInformation = true;
337
            $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...
338
            $dialoguserinfo['rdns'] = $this->rdns;
339
            $dialoguserinfo['user_agent'] = get_request()->getHeaderLine('user-agent');
340
        }
341
342
        // Captcha form
343
        $form = $this->getCurrentUrl();
344
        $captchas = $this->captcha;
345
346
        $ui = [
347
            'background_image' => '',
348
            'bg_color'         => '#ffffff',
349
            'header_bg_color'  => '#212531',
350
            'header_color'     => '#ffffff',
351
            'shadow_opacity'   => '0.2',
352
        ];
353
354
        foreach (array_keys($ui) as $key) {
355
            if (!empty($this->dialogUI[$key])) {
356
                $ui[$key] = $this->dialogUI[$key];
357
            }
358
        }
359
360
        if (!defined('SHIELDON_VIEW')) {
361
            define('SHIELDON_VIEW', true);
362
        }
363
364
        $css = require $this->getTemplate('css/default');
365
366
        ob_start();
367
        require $viewPath;
368
        $output = ob_get_contents();
369
        ob_end_clean();
370
371
        // Remove unused variable notices generated from PHP intelephense.
372
        unset(
373
            $css,
374
            $ui,
375
            $form,
376
            $captchas,
377
            $csrf,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $csrf seems to be never defined.
Loading history...
378
            $langCode,
379
            $showOnlineInformation,
380
            $showUserInformation
381
        );
382
383
        $stream = $response->getBody();
384
        $stream->write($output);
385
        $stream->rewind();
386
387
        return $response->
388
            withHeader('X-Protected-By', 'shieldon.io')->
389
            withBody($stream)->
390
            withStatus($statusCode);
391
    }
392
393
    /**
394
     * Ban an IP.
395
     *
396
     * @param string $ip A valid IP address.
397
     *
398
     * @return void
399
     */
400
    public function ban(string $ip = ''): void
401
    {
402
        if ('' === $ip) {
403
            $ip = $this->ip;
404
        }
405
 
406
        $this->action(
407
            self::ACTION_DENY,
408
            self::REASON_MANUAL_BAN,
409
            $ip
410
        );
411
    }
412
413
    /**
414
     * Unban an IP.
415
     *
416
     * @param string $ip A valid IP address.
417
     *
418
     * @return void
419
     */
420
    public function unban(string $ip = ''): void
421
    {
422
        if ('' === $ip) {
423
            $ip = $this->ip;
424
        }
425
426
        $this->action(
427
            self::ACTION_UNBAN,
428
            self::REASON_MANUAL_BAN,
429
            $ip
430
        );
431
        $this->log(self::ACTION_UNBAN);
432
433
        $this->result = self::RESPONSE_ALLOW;
434
    }
435
436
    /**
437
     * Set a property setting.
438
     *
439
     * @param string $key   The key of a property setting.
440
     * @param mixed  $value The value of a property setting.
441
     *
442
     * @return void
443
     */
444
    public function setProperty(string $key = '', $value = '')
445
    {
446
        if (isset($this->properties[$key])) {
447
            $this->properties[$key] = $value;
448
        }
449
    }
450
451
    /**
452
     * Set the property settings.
453
     * 
454
     * @param array $settings The settings.
455
     *
456
     * @return void
457
     */
458
    public function setProperties(array $settings): void
459
    {
460
        foreach (array_keys($this->properties) as $k) {
461
            if (isset($settings[$k])) {
462
                $this->properties[$k] = $settings[$k];
463
            }
464
        }
465
    }
466
467
    /**
468
     * Strict mode.
469
     * This option will take effects to all components.
470
     * 
471
     * @param bool $bool Set true to enble strict mode, false to disable it overwise.
472
     *
473
     * @return void
474
     */
475
    public function setStrict(bool $bool)
476
    {
477
        $this->strictMode = $bool;
478
    }
479
480
    /**
481
     * Set a action log logger.
482
     *
483
     * @param ActionLogger $logger
484
     *
485
     * @return void
486
     */
487
    public function setLogger(ActionLogger $logger): void
488
    {
489
        $this->logger = $logger;
490
    }
491
492
    /**
493
     * Set the URLs you want them to be excluded them from protection.
494
     *
495
     * @param array $urls The list of URL want to be excluded.
496
     *
497
     * @return void
498
     */
499
    public function setExcludedUrls(array $urls = []): void
500
    {
501
        $this->excludedUrls = $urls;
502
    }
503
504
    /**
505
     * Set a closure function.
506
     *
507
     * @param string  $key     The name for the closure class.
508
     * @param Closure $closure An instance will be later called.
509
     *
510
     * @return void
511
     */
512
    public function setClosure(string $key, Closure $closure): void
513
    {
514
        $this->closures[$key] = $closure;
515
    }
516
517
    /**
518
     * Customize the dialog UI.
519
     *
520
     * @return void
521
     */
522
    public function setDialogUI(array $settings): void
523
    {
524
        $this->dialogUI = $settings;
525
    }
526
527
    /**
528
     * Set the frontend template directory.
529
     *
530
     * @param string $directory
531
     *
532
     * @return void
533
     */
534
    public function setTemplateDirectory(string $directory)
535
    {
536
        if (!is_dir($directory)) {
537
            throw new InvalidArgumentException('The template directory does not exist.');
538
        }
539
        $this->templateDirectory = $directory;
540
    }
541
542
    /**
543
     * Get a template PHP file.
544
     *
545
     * @param string $type The template type.
546
     *
547
     * @return string
548
     */
549
    protected function getTemplate(string $type): string
550
    {
551
        $directory = self::KERNEL_DIR . '/../../templates/frontend';
552
553
        if (!empty($this->templateDirectory)) {
554
            $directory = $this->templateDirectory;
555
        }
556
557
        $path = $directory . '/' . $type . '.php';
558
559
        if (!file_exists($path)) {
560
            throw new RuntimeException(
561
                sprintf(
562
                    'The templeate file is missing. (%s)',
563
                    $path
564
                )
565
            );
566
        }
567
568
        return $path;
569
    }
570
571
    /**
572
     * Get a class name without namespace string.
573
     *
574
     * @param object $instance Class
575
     * 
576
     * @return void
577
     */
578
    protected function getClassName($instance): string
579
    {
580
        $class = get_class($instance);
581
        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...
582
    }
583
584
    /**
585
     * Print a JavasSript snippet in your webpages.
586
     * 
587
     * This snippet generate cookie on client's browser,then we check the 
588
     * cookie to identify the client is a rebot or not.
589
     *
590
     * @return string
591
     */
592
    public function getJavascript(): string
593
    {
594
        $tmpCookieName = $this->properties['cookie_name'];
595
        $tmpCookieDomain = $this->properties['cookie_domain'];
596
597
        if (empty($tmpCookieDomain) && get_request()->getHeaderLine('host')) {
598
            $tmpCookieDomain = get_request()->getHeaderLine('host');
599
        }
600
601
        $tmpCookieValue = $this->properties['cookie_value'];
602
603
        $jsString = '
604
            <script>
605
                var d = new Date();
606
                d.setTime(d.getTime()+(60*60*24*30));
607
                document.cookie = "' . $tmpCookieName . '=' . $tmpCookieValue . ';domain=.' . $tmpCookieDomain . ';expires="+d.toUTCString();
608
            </script>
609
        ';
610
611
        return $jsString;
612
    }
613
614
    /**
615
     * Get current visior's path.
616
     *
617
     * @return string
618
     */
619
    public function getCurrentUrl(): string
620
    {
621
        return get_request()->getUri()->getPath();
622
    }
623
624
    /**
625
     * Displayed on Firewall Panel, tell you current what type of current
626
     * configuration is used for.
627
     * 
628
     * @param string $type The type of configuration.
629
     *                     demo | managed | config
630
     *
631
     * @return void
632
     */
633
    public function managedBy(string $type = ''): void
634
    {
635
        if (in_array($type, ['managed', 'config', 'demo'])) {
636
            $this->firewallType = $type;
637
        }
638
    }
639
640
    /*
641
    |-------------------------------------------------------------------
642
    | Non-public methids.
643
    |-------------------------------------------------------------------
644
    */
645
646
    /**
647
     * Run, run, run!
648
     *
649
     * Check the rule tables first, if an IP address has been listed.
650
     * Call function filter() if an IP address is not listed in rule tables.
651
     *
652
     * @return int The response code.
653
     */
654
    protected function process(): int
655
    {
656
        $this->driver->init($this->autoCreateDatabase);
657
658
        $this->initComponents();
659
660
        $processMethods = [
661
            'isRuleExist',   // Stage 1. - Looking for rule table.
662
            'isTrustedBot',  // Stage 2 - Detect popular search engine.
663
            'isFakeRobot',   // Stage 3 - Reject fake search engine crawlers.
664
            'isIpComponent', // Stage 4 - IP manager.
665
            'isComponents'    // Stage 5 - Check other components.
666
        ];
667
668
        foreach ($processMethods as $method) {
669
            if ($this->{$method}()) {
670
                return $this->result;
671
            }
672
        }
673
674
        // Stage 6 - Check filters if set.
675
        if (array_search(true, $this->filterStatus)) {
676
            return $this->result = $this->sessionHandler($this->filter());
677
        }
678
679
        // Stage 7 - Go into session limit check.
680
        return $this->result = $this->sessionHandler(self::RESPONSE_ALLOW);
681
    }
682
683
    /**
684
     * Start an action for this IP address, allow or deny, and give a reason for it.
685
     *
686
     * @param int    $actionCode - 0: deny, 1: allow, 9: unban.
687
     * @param string $reasonCode
688
     * @param string $assignIp
689
     * 
690
     * @return void
691
     */
692
    protected function action(int $actionCode, int $reasonCode, string $assignIp = ''): void
693
    {
694
        $ip = $this->ip;
695
        $rdns = $this->rdns;
696
        $now = time();
697
        $logData = [];
698
    
699
        if ('' !== $assignIp) {
700
            $ip = $assignIp;
701
            $rdns = gethostbyaddr($ip);
702
        }
703
704
        if ($actionCode === self::ACTION_UNBAN) {
705
            $this->driver->delete($ip, 'rule');
706
        } else {
707
            $logData['log_ip']     = $ip;
708
            $logData['ip_resolve'] = $rdns;
709
            $logData['time']       = $now;
710
            $logData['type']       = $actionCode;
711
            $logData['reason']     = $reasonCode;
712
            $logData['attempts']   = 0;
713
714
            $this->driver->save($ip, $logData, 'rule');
715
        }
716
717
        // Remove logs for this IP address because It already has it's own rule on system.
718
        // No need to count for it anymore.
719
        $this->driver->delete($ip, 'filter');
720
721
        // Log this action.
722
        $this->log($actionCode, $ip);
723
    }
724
725
    /**
726
     * Log actions.
727
     *
728
     * @param int $actionCode The code number of the action.
729
     *
730
     * @return void
731
     */
732
    protected function log(int $actionCode, $ip = ''): void
733
    {
734
        if (!$this->logger) {
735
            return;
736
        }
737
738
        $logData = [];
739
        $logData['ip'] = $ip ?? $this->getIp();
740
        $logData['session_id'] = get_session()->get('id');
741
        $logData['action_code'] = $actionCode;
742
        $logData['timesamp'] = time();
743
744
        $this->logger->add($logData);
745
    }
746
}
747