Passed
Push — master ( 48e2e4...58f05f )
by Marwan
08:52
created

CSRF::isWhitelisted()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
c 1
b 0
f 0
dl 0
loc 8
ccs 5
cts 5
cp 1
crap 2
rs 10
eloc 5
nc 2
nop 0
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend\Session;
13
14
use MAKS\Velox\App;
15
use MAKS\Velox\Backend\Config;
16
use MAKS\Velox\Backend\Globals;
17
use MAKS\Velox\Backend\Session;
18
use MAKS\Velox\Frontend\HTML;
19
20
/**
21
 * A class that offers a simple interface to protect against Cross-Site Request Forgery.
22
 *
23
 * Example:
24
 * ```
25
 * // create, store, and return a token
26
 * $csrf = new CSRF();
27
 * $token = $csrf->token();
28
 *
29
 * // render a hidden input field containing the token
30
 * $html = $csrf->html();
31
 *
32
 * // check if the token is valid
33
 * $csrf->check();
34
 *
35
 * // validate if request token matches the stored token
36
 * $status = $csrf->isValid();
37
 * ```
38
 *
39
 * @package Velox\Backend\Session
40
 * @since 1.5.4
41
 */
42
class CSRF
43
{
44
    private string $name;
45
46
    private string $token;
47
48
49
    /**
50
     * Class constructor.
51
     *
52
     * @param string|null $name The name of the CSRF token (input field).
53
     */
54 5
    public function __construct(?string $name = null)
55
    {
56 5
        Session::start();
57
58 5
        $this->name  = $name ?? Config::get('session.csrf.name', '_token');
59 5
        $this->token = Session::get($this->name) ?? '';
60 5
    }
61
62
    /**
63
     * Returns the HTML input element containing the CSRF token.
64
     */
65 1
    public function __toString()
66
    {
67 1
        return $this->html();
68
    }
69
70
71
    /**
72
     * Returns an HTML input element containing a CSRF token after storing it in the session.
73
     * This method will be called automatically if the object is casted to a string.
74
     *
75
     * @return string
76
     */
77 1
    public function html(): string
78
    {
79 1
        return HTML::input(null, [
80 1
            'type'  => 'hidden',
81 1
            'name'  => $this->name,
82 1
            'value' => $this->token()
83
        ]);
84
    }
85
86
    /**
87
     * Generates a CSRF token, stores it in the session and returns it.
88
     *
89
     * @return string The CSRF token.
90
     */
91 1
    public function token(): string
92
    {
93 1
        $this->token = empty($this->token) ? bin2hex(random_bytes(64)) : $this->token;
94
95 1
        Session::get($this->name, $this->token);
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Session::get() has too many arguments starting with $this->token. ( Ignorable by Annotation )

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

95
        Session::/** @scrutinizer ignore-call */ 
96
                 get($this->name, $this->token);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
96
97 1
        return $this->token;
98
    }
99
100
    /**
101
     * Checks whether the request token matches the token stored in the session.
102
     *
103
     * @return bool
104
     */
105 5
    public function check(): void
106
    {
107 5
        if ($this->isValid()) {
108 4
            return;
109
        }
110
111 1
        $this->fail();
112
    }
113
114
    /**
115
     * Renders 403 error page.
116
     *
117
     * @return void
118
     *
119
     * @codeCoverageIgnore Can't test methods that send headers.
120
     */
121
    public static function fail(): void
122
    {
123
        App::log('Responded with 403 to the request for "{uri}". CSRF is detected. Client IP address {ip}', [
124
            'uri' => Globals::getServer('REQUEST_URI'),
125
            'ip'  => Globals::getServer('REMOTE_ADDR'),
126
        ], 'system');
127
128
        App::abort(403, null, 'Invalid CSRF token!');
129
    }
130
131
    /**
132
     * Validate the request token with the token stored in the session.
133
     *
134
     * @return bool Whether the request token matches the stored one or not.
135
     */
136 5
    public function isValid(): bool
137
    {
138 5
        if ($this->isWhitelisted() || $this->isIdentical()) {
139 5
            return true;
140
        }
141
142 1
        Session::cut($this->name);
143
144 1
        return false;
145
    }
146
147 5
    private function isWhitelisted(): bool
148
    {
149 5
        $method = Globals::getServer('REQUEST_METHOD');
150 5
        $client = Globals::getServer('REMOTE_HOST') ?? Globals::getServer('REMOTE_ADDR');
151
152
        return (
153 5
            in_array($client, Config::get('session.csrf.whitelisted', [])) ||
154 5
            !in_array($method, Config::get('session.csrf.methods', []))
155
        );
156
    }
157
158 1
    private function isIdentical(): bool
159
    {
160 1
        $token = Globals::cutPost($this->name) ?? Globals::cutGet($this->name) ?? '';
161
162 1
        return empty($this->token) || hash_equals($this->token, $token);
163
    }
164
}
165