• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 12607497419

04 Jan 2025 04:25AM UTC coverage: 84.454%. Remained the same
12607497419

Pull #9365

github

web-flow
Merge fa357991c into 046967af0
Pull Request #9365: fix: ensure csrf token is string

7 of 7 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

20464 of 24231 relevant lines covered (84.45%)

189.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.28
/system/Security/Security.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Security;
15

16
use CodeIgniter\Cookie\Cookie;
17
use CodeIgniter\HTTP\IncomingRequest;
18
use CodeIgniter\HTTP\Method;
19
use CodeIgniter\HTTP\Request;
20
use CodeIgniter\HTTP\RequestInterface;
21
use CodeIgniter\I18n\Time;
22
use CodeIgniter\Security\Exceptions\SecurityException;
23
use CodeIgniter\Session\Session;
24
use Config\Cookie as CookieConfig;
25
use Config\Security as SecurityConfig;
26
use ErrorException;
27
use InvalidArgumentException;
28
use LogicException;
29

30
/**
31
 * Class Security
32
 *
33
 * Provides methods that help protect your site against
34
 * Cross-Site Request Forgery attacks.
35
 *
36
 * @see \CodeIgniter\Security\SecurityTest
37
 */
38
class Security implements SecurityInterface
39
{
40
    public const CSRF_PROTECTION_COOKIE  = 'cookie';
41
    public const CSRF_PROTECTION_SESSION = 'session';
42
    protected const CSRF_HASH_BYTES      = 16;
43

44
    /**
45
     * CSRF Protection Method
46
     *
47
     * Protection Method for Cross Site Request Forgery protection.
48
     *
49
     * @var string 'cookie' or 'session'
50
     *
51
     * @deprecated 4.4.0 Use $this->config->csrfProtection.
52
     */
53
    protected $csrfProtection = self::CSRF_PROTECTION_COOKIE;
54

55
    /**
56
     * CSRF Token Randomization
57
     *
58
     * @var bool
59
     *
60
     * @deprecated 4.4.0 Use $this->config->tokenRandomize.
61
     */
62
    protected $tokenRandomize = false;
63

64
    /**
65
     * CSRF Hash (without randomization)
66
     *
67
     * Random hash for Cross Site Request Forgery protection.
68
     *
69
     * @var string|null
70
     */
71
    protected $hash;
72

73
    /**
74
     * CSRF Token Name
75
     *
76
     * Token name for Cross Site Request Forgery protection.
77
     *
78
     * @var string
79
     *
80
     * @deprecated 4.4.0 Use $this->config->tokenName.
81
     */
82
    protected $tokenName = 'csrf_token_name';
83

84
    /**
85
     * CSRF Header Name
86
     *
87
     * Header name for Cross Site Request Forgery protection.
88
     *
89
     * @var string
90
     *
91
     * @deprecated 4.4.0 Use $this->config->headerName.
92
     */
93
    protected $headerName = 'X-CSRF-TOKEN';
94

95
    /**
96
     * The CSRF Cookie instance.
97
     *
98
     * @var Cookie
99
     */
100
    protected $cookie;
101

102
    /**
103
     * CSRF Cookie Name (with Prefix)
104
     *
105
     * Cookie name for Cross Site Request Forgery protection.
106
     *
107
     * @var string
108
     */
109
    protected $cookieName = 'csrf_cookie_name';
110

111
    /**
112
     * CSRF Expires
113
     *
114
     * Expiration time for Cross Site Request Forgery protection cookie.
115
     *
116
     * Defaults to two hours (in seconds).
117
     *
118
     * @var int
119
     *
120
     * @deprecated 4.4.0 Use $this->config->expires.
121
     */
122
    protected $expires = 7200;
123

124
    /**
125
     * CSRF Regenerate
126
     *
127
     * Regenerate CSRF Token on every request.
128
     *
129
     * @var bool
130
     *
131
     * @deprecated 4.4.0 Use $this->config->regenerate.
132
     */
133
    protected $regenerate = true;
134

135
    /**
136
     * CSRF Redirect
137
     *
138
     * Redirect to previous page with error on failure.
139
     *
140
     * @var bool
141
     *
142
     * @deprecated 4.4.0 Use $this->config->redirect.
143
     */
144
    protected $redirect = false;
145

146
    /**
147
     * CSRF SameSite
148
     *
149
     * Setting for CSRF SameSite cookie token.
150
     *
151
     * Allowed values are: None - Lax - Strict - ''.
152
     *
153
     * Defaults to `Lax` as recommended in this link:
154
     *
155
     * @see https://portswigger.net/web-security/csrf/samesite-cookies
156
     *
157
     * @var string
158
     *
159
     * @deprecated `Config\Cookie` $samesite property is used.
160
     */
161
    protected $samesite = Cookie::SAMESITE_LAX;
162

163
    private readonly IncomingRequest $request;
164

165
    /**
166
     * CSRF Cookie Name without Prefix
167
     */
168
    private ?string $rawCookieName = null;
169

170
    /**
171
     * Session instance.
172
     */
173
    private ?Session $session = null;
174

175
    /**
176
     * CSRF Hash in Request Cookie
177
     *
178
     * The cookie value is always CSRF hash (without randomization) even if
179
     * $tokenRandomize is true.
180
     */
181
    private ?string $hashInCookie = null;
182

183
    /**
184
     * Security Config
185
     */
186
    protected SecurityConfig $config;
187

188
    /**
189
     * Constructor.
190
     *
191
     * Stores our configuration and fires off the init() method to setup
192
     * initial state.
193
     */
194
    public function __construct(SecurityConfig $config)
195
    {
196
        $this->config = $config;
99✔
197

198
        $this->rawCookieName = $config->cookieName;
99✔
199

200
        if ($this->isCSRFCookie()) {
99✔
201
            $cookie = config(CookieConfig::class);
73✔
202

203
            $this->configureCookie($cookie);
73✔
204
        } else {
205
            // Session based CSRF protection
206
            $this->configureSession();
26✔
207
        }
208

209
        $this->request      = service('request');
99✔
210
        $this->hashInCookie = $this->request->getCookie($this->cookieName);
99✔
211

212
        $this->restoreHash();
99✔
213
        if ($this->hash === null) {
99✔
214
            $this->generateHash();
59✔
215
        }
216
    }
217

218
    private function isCSRFCookie(): bool
219
    {
220
        return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE;
99✔
221
    }
222

223
    private function configureSession(): void
224
    {
225
        $this->session = service('session');
26✔
226
    }
227

228
    private function configureCookie(CookieConfig $cookie): void
229
    {
230
        $cookiePrefix     = $cookie->prefix;
73✔
231
        $this->cookieName = $cookiePrefix . $this->rawCookieName;
73✔
232
        Cookie::setDefaults($cookie);
73✔
233
    }
234

235
    /**
236
     * CSRF verification.
237
     *
238
     * @return $this
239
     *
240
     * @throws SecurityException
241
     */
242
    public function verify(RequestInterface $request)
243
    {
244
        // Protects POST, PUT, DELETE, PATCH
245
        $method           = $request->getMethod();
39✔
246
        $methodsToProtect = [Method::POST, Method::PUT, Method::DELETE, Method::PATCH];
39✔
247
        if (! in_array($method, $methodsToProtect, true)) {
39✔
248
            return $this;
3✔
249
        }
250

251
        $postedToken = $this->getPostedToken($request);
36✔
252

253
        try {
254
            $token = ($postedToken !== null && $this->config->tokenRandomize)
36✔
255
                ? $this->derandomize($postedToken) : $postedToken;
36✔
256
        } catch (InvalidArgumentException) {
1✔
257
            $token = null;
1✔
258
        }
259

260
        // Do the tokens match?
261
        if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) {
36✔
262
            throw SecurityException::forDisallowedAction();
14✔
263
        }
264

265
        $this->removeTokenInRequest($request);
22✔
266

267
        if ($this->config->regenerate) {
22✔
268
            $this->generateHash();
18✔
269
        }
270

271
        log_message('info', 'CSRF token verified.');
22✔
272

273
        return $this;
22✔
274
    }
275

276
    /**
277
     * Remove token in POST or JSON request data
278
     */
279
    private function removeTokenInRequest(RequestInterface $request): void
280
    {
281
        assert($request instanceof Request);
282

283
        if (isset($_POST[$this->config->tokenName])) {
22✔
284
            // We kill this since we're done and we don't want to pollute the POST array.
285
            unset($_POST[$this->config->tokenName]);
11✔
286
            $request->setGlobal('post', $_POST);
11✔
287
        } else {
288
            $body = $request->getBody() ?? '';
11✔
289
            $json = json_decode($body);
11✔
290
            if ($json !== null && json_last_error() === JSON_ERROR_NONE) {
11✔
291
                // We kill this since we're done and we don't want to pollute the JSON data.
292
                unset($json->{$this->config->tokenName});
3✔
293
                $request->setBody(json_encode($json));
3✔
294
            } else {
295
                parse_str($body, $parsed);
8✔
296
                // We kill this since we're done and we don't want to pollute the BODY data.
297
                unset($parsed[$this->config->tokenName]);
8✔
298
                $request->setBody(http_build_query($parsed));
8✔
299
            }
300
        }
301
    }
302

303
    private function getPostedToken(RequestInterface $request): ?string
304
    {
305
        assert($request instanceof IncomingRequest);
306

307
        // Does the token exist in POST, HEADER or optionally php:://input - json data or PUT, DELETE, PATCH - raw data.
308

309
        if ($tokenValue = $request->getPost($this->config->tokenName)) {
36✔
310
            return is_string($tokenValue) ? $tokenValue : null;
15✔
311
        }
312

313
        if ($request->hasHeader($this->config->headerName)
21✔
314
            && $request->header($this->config->headerName)->getValue() !== ''
21✔
315
            && $request->header($this->config->headerName)->getValue() !== []) {
21✔
316
            return $request->header($this->config->headerName)->getValue();
10✔
317
        }
318

319
        $body = (string) $request->getBody();
11✔
320

321
        if ($body !== '') {
11✔
322
            $json = json_decode($body);
10✔
323
            if ($json !== null && json_last_error() === JSON_ERROR_NONE) {
10✔
324
                return $json->{$this->config->tokenName} ?? null;
6✔
325
            }
326

327
            parse_str($body, $parsed);
4✔
328

329
            return $parsed[$this->config->tokenName] ?? null;
4✔
330
        }
331

332
        return null;
1✔
333
    }
334

335
    /**
336
     * Returns the CSRF Token.
337
     */
338
    public function getHash(): ?string
339
    {
340
        return $this->config->tokenRandomize ? $this->randomize($this->hash) : $this->hash;
23✔
341
    }
342

343
    /**
344
     * Randomize hash to avoid BREACH attacks.
345
     *
346
     * @params string $hash CSRF hash
347
     *
348
     * @return string CSRF token
349
     */
350
    protected function randomize(string $hash): string
351
    {
352
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
1✔
353
        $hashBinary = hex2bin($hash);
1✔
354

355
        if ($hashBinary === false) {
1✔
UNCOV
356
            throw new LogicException('$hash is invalid: ' . $hash);
×
357
        }
358

359
        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
1✔
360
    }
361

362
    /**
363
     * Derandomize the token.
364
     *
365
     * @params string $token CSRF token
366
     *
367
     * @return string CSRF hash
368
     *
369
     * @throws InvalidArgumentException "hex2bin(): Hexadecimal input string must have an even length"
370
     */
371
    protected function derandomize(string $token): string
372
    {
373
        $key   = substr($token, -static::CSRF_HASH_BYTES * 2);
13✔
374
        $value = substr($token, 0, static::CSRF_HASH_BYTES * 2);
13✔
375

376
        try {
377
            return bin2hex(hex2bin($value) ^ hex2bin($key));
13✔
378
        } catch (ErrorException $e) {
1✔
379
            // "hex2bin(): Hexadecimal input string must have an even length"
380
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
1✔
381
        }
382
    }
383

384
    /**
385
     * Returns the CSRF Token Name.
386
     */
387
    public function getTokenName(): string
388
    {
389
        return $this->config->tokenName;
9✔
390
    }
391

392
    /**
393
     * Returns the CSRF Header Name.
394
     */
395
    public function getHeaderName(): string
396
    {
397
        return $this->config->headerName;
3✔
398
    }
399

400
    /**
401
     * Returns the CSRF Cookie Name.
402
     */
403
    public function getCookieName(): string
404
    {
405
        return $this->config->cookieName;
3✔
406
    }
407

408
    /**
409
     * Check if request should be redirect on failure.
410
     */
411
    public function shouldRedirect(): bool
412
    {
413
        return $this->config->redirect;
1✔
414
    }
415

416
    /**
417
     * Sanitize Filename
418
     *
419
     * Tries to sanitize filenames in order to prevent directory traversal attempts
420
     * and other security threats, which is particularly useful for files that
421
     * were supplied via user input.
422
     *
423
     * If it is acceptable for the user input to include relative paths,
424
     * e.g. file/in/some/approved/folder.txt, you can set the second optional
425
     * parameter, $relativePath to TRUE.
426
     *
427
     * @param string $str          Input file name
428
     * @param bool   $relativePath Whether to preserve paths
429
     */
430
    public function sanitizeFilename(string $str, bool $relativePath = false): string
431
    {
432
        // List of sanitize filename strings
433
        $bad = [
3✔
434
            '../',
3✔
435
            '<!--',
3✔
436
            '-->',
3✔
437
            '<',
3✔
438
            '>',
3✔
439
            "'",
3✔
440
            '"',
3✔
441
            '&',
3✔
442
            '$',
3✔
443
            '#',
3✔
444
            '{',
3✔
445
            '}',
3✔
446
            '[',
3✔
447
            ']',
3✔
448
            '=',
3✔
449
            ';',
3✔
450
            '?',
3✔
451
            '%20',
3✔
452
            '%22',
3✔
453
            '%3c',
3✔
454
            '%253c',
3✔
455
            '%3e',
3✔
456
            '%0e',
3✔
457
            '%28',
3✔
458
            '%29',
3✔
459
            '%2528',
3✔
460
            '%26',
3✔
461
            '%24',
3✔
462
            '%3f',
3✔
463
            '%3b',
3✔
464
            '%3d',
3✔
465
        ];
3✔
466

467
        if (! $relativePath) {
3✔
468
            $bad[] = './';
3✔
469
            $bad[] = '/';
3✔
470
        }
471

472
        $str = remove_invisible_characters($str, false);
3✔
473

474
        do {
475
            $old = $str;
3✔
476
            $str = str_replace($bad, '', $str);
3✔
477
        } while ($old !== $str);
3✔
478

479
        return stripslashes($str);
3✔
480
    }
481

482
    /**
483
     * Restore hash from Session or Cookie
484
     */
485
    private function restoreHash(): void
486
    {
487
        if ($this->isCSRFCookie()) {
99✔
488
            if ($this->isHashInCookie()) {
73✔
489
                $this->hash = $this->hashInCookie;
15✔
490
            }
491
        } elseif ($this->session->has($this->config->tokenName)) {
26✔
492
            // Session based CSRF protection
493
            $this->hash = $this->session->get($this->config->tokenName);
26✔
494
        }
495
    }
496

497
    /**
498
     * Generates (Regenerates) the CSRF Hash.
499
     */
500
    public function generateHash(): string
501
    {
502
        $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES));
78✔
503

504
        if ($this->isCSRFCookie()) {
78✔
505
            $this->saveHashInCookie();
66✔
506
        } else {
507
            // Session based CSRF protection
508
            $this->saveHashInSession();
12✔
509
        }
510

511
        return $this->hash;
78✔
512
    }
513

514
    private function isHashInCookie(): bool
515
    {
516
        if ($this->hashInCookie === null) {
73✔
517
            return false;
59✔
518
        }
519

520
        $length  = static::CSRF_HASH_BYTES * 2;
15✔
521
        $pattern = '#^[0-9a-f]{' . $length . '}$#iS';
15✔
522

523
        return preg_match($pattern, $this->hashInCookie) === 1;
15✔
524
    }
525

526
    private function saveHashInCookie(): void
527
    {
528
        $this->cookie = new Cookie(
66✔
529
            $this->rawCookieName,
66✔
530
            $this->hash,
66✔
531
            [
66✔
532
                'expires' => $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires,
66✔
533
            ]
66✔
534
        );
66✔
535

536
        $response = service('response');
66✔
537
        $response->setCookie($this->cookie);
66✔
538
    }
539

540
    private function saveHashInSession(): void
541
    {
542
        $this->session->set($this->config->tokenName, $this->hash);
12✔
543
    }
544
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc