-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathCSRFProtector.php
345 lines (306 loc) · 10.2 KB
/
CSRFProtector.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
<?php
namespace Ling\CSRFTools;
use Ling\ArrayToString\ArrayToStringTool;
/**
* The CSRFProtector class.
*
* This class can be used as a singleton, or as a regular class (it's an hybrid).
*
*
* How this class works
* ================
* Before using this class, you should have a basic understanding of how it works.
*
* In this class, a token is composed of two things:
*
* - a name
* - a value
*
* The value of the token is always generated by this class, it's a random value, like 98f0z0fzegijzgozhu for instance.
* The name of the token is chosen by you.
*
* With this class, you can generate tokens, using the createToken method.
* This method will not only return the value of the token, but also store it in the php session.
*
* You can then check whether a given token has the value you think it has (using the isValid method): this is the
* heart of the CSRF validation; if you can predict the value of a token, then you must be the one who created it (at least
* that's the logic behind it).
*
* Now what's peculiar to this class is that the tokens are stored in slots.
* More precisely, there are two possible slots:
* - new
* - old
*
* By default, the createToken method stores a token in the new slot.
* But then if you call the createToken method again with the same token name, the "new" slot will be replaced by
* a new token value (random string generated by this class), but the old token value is transferred to the
* "old" slot rather than being completely replaced.
*
* Why is that so you might ask?
*
* This has to do with how forms are implemented in php applications.
* A form is first displayed, then the user posts the form.
* In terms of page invocation, this means that the form page is usually invoked at least twice:
* - the first time to display the form to the user
* - the second time to test the posted data against some validation mechanism
*
*
* And so the first time we create the token and it goes to the new slot.
* And the second time, we want to validate the token, but a new token has been regenerated (since we posted the form
* and the page has been refreshed), and so we want to validate the token against the old slot.
*
*
* Now which slot you want to validate against really depends on your application and your concrete case.
*
* For instance for csrf protected backend services accessed via ajax, we generally want to use the new slot all the time.
* When the user calls the page, we generate the token, but when he calls the service via ajax, the page generating the token
* has not been recalled, and so the token is still in the "new slot".
*
* That's why the isValid method let you choose which slot you want to validate against.
*
*
*
*
* The delete method
* -------------
*
* I would recommend using it wherever you can, because it completely prevents an attacker from guessing the token.
* However in some cases, the token cannot be deleted otherwise your own users cannot use them.
* So, you have to assess the situation for yourself, and decide whether you should use the delete method.
*
*
*
*
*
*
*/
class CSRFProtector
{
/**
* This property holds its own instance.
* @var CSRFProtector
*/
private static $inst = null;
/**
* This property holds the sessionName for this instance.
*
* It's like a namespace containing all tokens generated by this class.
*
* You shouldn't change this, as it's unlikely that you would have a session variable named csrf_tools_token.
* But if you were, you could extend this class and change that sessionName.
*
*
*
* @var string
*/
protected $sessionName;
/**
* This property holds the usePage for this instance.
* See the @page(page security conception notes) for more details.
* @var bool=true
*/
protected $usePage;
/**
* Gets the singleton instance for this class.
*
*
* @return CSRFProtector
*/
public static function inst()
{
if (null === self::$inst) {
self::$inst = new static();
}
return self::$inst;
}
/**
* Builds the CSRFProtector instance.
*/
public function __construct()
{
$this->sessionName = "csrf_tools_token";
$this->usePage = true;
$this->startSession();
}
/**
* Sets the usePage.
*
* @param bool $usePage
*/
public function setUsePage(bool $usePage)
{
$this->usePage = $usePage;
}
/**
* Creates the token named $tokenName, stores its value in the "new" slot, and returns the token value.
* If the token named $tokenName already exists, there is a rotation: the newly created token is stored in the "new" slot,
* while the old "new" value (found in the "new" slot before it was replaced) is moved to the "old" slot.
*
* For more details, please refer to this class description.
*
* The following token names are reserved for internal use and must not be used:
*
* - __pages__
*
*
* @param string $tokenName
* @return string
*/
public function createToken(string $tokenName): string
{
if (array_key_exists($tokenName, $_SESSION[$this->sessionName])) {
$_SESSION[$this->sessionName][$tokenName]['old'] = $_SESSION[$this->sessionName][$tokenName]['new'];
}
$token = md5(uniqid());
$_SESSION[$this->sessionName][$tokenName]['new'] = $token;
if (true === $this->usePage) {
$this->addTokenForPage($tokenName);
}
return $token;
}
/**
* Returns whether the token identified by the given tokenName is already stored in the session.
*
*
* @param string $tokenName
* @return bool
*/
public function hasToken(string $tokenName): bool
{
$this->startSession();
if (array_key_exists($tokenName, $_SESSION[$this->sessionName])) {
return array_key_exists($tokenName, $_SESSION[$this->sessionName]);
}
return false;
}
/**
* Returns whether the given $tokenName exists and has the given $tokenValue.
*
*
* @param string $tokenName
* @param string $tokenValue
* @param bool=false $useNewSlot
* @return bool
*/
public function isValid(string $tokenName, string $tokenValue, bool $useNewSlot = false): bool
{
$this->startSession();
if (array_key_exists($tokenName, $_SESSION[$this->sessionName])) {
$tokenValue = trim($tokenValue);
if (false === $useNewSlot) {
if (array_key_exists("old", $_SESSION[$this->sessionName][$tokenName])) {
$res = ($tokenValue === $_SESSION[$this->sessionName][$tokenName]["old"]);
} else {
$res = false;
}
} else {
$res = ($tokenValue === $_SESSION[$this->sessionName][$tokenName]["new"]);
}
return $res;
}
return false;
}
/**
* Deletes the given $tokenName.
*
* @param string $tokenName
*/
public function deleteToken(string $tokenName)
{
$this->startSession();
unset($_SESSION[$this->sessionName][$tokenName]);
}
/**
* Deletes the tokens that are not associated with the current page.
*
*/
public function deletePageUnusedTokens()
{
$this->startSession();
$pageId = $this->getPageId();
$pages = $_SESSION[$this->sessionName]['__pages__'];
/**
* I had this issue where the system thought it was another page, and so deleted tokens that were on the
* actual page.
*
* To reproduce:
*
* - browse /reset.php to clean all session variables
* - browse /user/profile?d (create a fake get variable to make the old system think it's another page)
* - log in, and now browse /user/profile. As you browse /user/profile, the old system was removing the current
* tokens, because /user/profile?d is different than /user/profile, but it shouldn't because we
* actually use the tokens on the page. Anyway, this is fixed simply by protecting the tokens
* on the current page (variable currentTokens below).
*
*
*
*/
$currentTokens = $pages[$pageId] ?? [];
unset($pages[$pageId]);
array_walk_recursive($pages, function ($tokenName) use ($currentTokens) {
if (false === in_array($tokenName, $currentTokens, true)) {
unset($_SESSION[$this->sessionName][$tokenName]);
}
});
}
/**
* Returns a debug string of the php session content.
*
* @return string
*/
public function dump(): string
{
$this->startSession();
return ArrayToStringTool::toPhpArray($_SESSION[$this->sessionName]);
}
/**
* Cleans the session.
*/
public function cleanSession()
{
$this->startSession();
unset($_SESSION[$this->sessionName]);
}
//--------------------------------------------
//
//--------------------------------------------
/**
* Ensures that the php session has started.
*/
protected function startSession()
{
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
if (false === array_key_exists($this->sessionName, $_SESSION)) {
$_SESSION[$this->sessionName] = [
'__pages__' => [],
];
}
}
/**
* Adds a token to the pages array.
*
* @param string $tokenName
*/
protected function addTokenForPage(string $tokenName)
{
$pages = $_SESSION[$this->sessionName]['__pages__'] ?? [];
$pageId = $this->getPageId();
if (false === array_key_exists($pageId, $pages)) {
$pages[$pageId] = [];
}
if (false === in_array($tokenName, $pages[$pageId])) {
$pages[$pageId][] = $tokenName;
$_SESSION[$this->sessionName]['__pages__'] = $pages;
}
}
/**
* Returns the current page id.
* @return string
*/
protected function getPageId(): string
{
return $_SERVER['REQUEST_URI'];
}
}