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

codeigniter4 / CodeIgniter4 / 22255105287

21 Feb 2026 10:20AM UTC coverage: 86.022% (+0.05%) from 85.977%
22255105287

Pull #9970

github

web-flow
Merge daf6d6b5a into 52011eaa8
Pull Request #9970: feat: Add Global Context feature

61 of 61 new or added lines in 4 files covered. (100.0%)

1 existing line in 1 file now uncovered.

22340 of 25970 relevant lines covered (86.02%)

207.62 hits per line

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

99.15
/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\Exceptions\InvalidArgumentException;
18
use CodeIgniter\Exceptions\LogicException;
19
use CodeIgniter\HTTP\IncomingRequest;
20
use CodeIgniter\HTTP\Method;
21
use CodeIgniter\HTTP\RequestInterface;
22
use CodeIgniter\I18n\Time;
23
use CodeIgniter\Security\Exceptions\SecurityException;
24
use CodeIgniter\Session\Session;
25
use Config\Cookie as CookieConfig;
26
use Config\Security as SecurityConfig;
27
use ErrorException;
28
use JsonException;
29
use SensitiveParameter;
30

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

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

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

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

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

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

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

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

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

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

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

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

164
    private readonly IncomingRequest $request;
165

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

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

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

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

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

199
        $this->rawCookieName = $config->cookieName;
111✔
200

201
        if ($this->isCSRFCookie()) {
111✔
202
            $cookie = config(CookieConfig::class);
85✔
203

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

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

213
        $this->restoreHash();
111✔
214
        if ($this->hash === null) {
111✔
215
            $this->generateHash();
71✔
216
        }
217
    }
218

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

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

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

236
    public function verify(RequestInterface $request)
237
    {
238
        $method = $request->getMethod();
39✔
239

240
        // Protect POST, PUT, DELETE, PATCH requests only
241
        if (! in_array($method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true)) {
39✔
242
            return $this;
3✔
243
        }
244

245
        assert($request instanceof IncomingRequest);
246

247
        $postedToken = $this->getPostedToken($request);
36✔
248

249
        try {
250
            $token = $postedToken !== null && $this->config->tokenRandomize
36✔
251
                ? $this->derandomize($postedToken)
13✔
252
                : $postedToken;
23✔
253
        } catch (InvalidArgumentException) {
1✔
254
            $token = null;
1✔
255
        }
256

257
        if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) {
36✔
258
            throw SecurityException::forDisallowedAction();
14✔
259
        }
260

261
        $this->removeTokenInRequest($request);
22✔
262

263
        if ($this->config->regenerate) {
22✔
264
            $this->generateHash();
18✔
265
        }
266

267
        log_message('info', 'CSRF token verified.');
22✔
268

269
        return $this;
22✔
270
    }
271

272
    /**
273
     * Remove token in POST or JSON request data
274
     */
275
    private function removeTokenInRequest(IncomingRequest $request): void
276
    {
277
        $superglobals = service('superglobals');
22✔
278
        $tokenName    = $this->config->tokenName;
22✔
279

280
        // If the token is found in POST data, we can safely remove it.
281
        if (is_string($superglobals->post($tokenName))) {
22✔
282
            $superglobals->unsetPost($tokenName);
11✔
283
            $request->setGlobal('post', $superglobals->getPostArray());
11✔
284

285
            return;
11✔
286
        }
287

288
        $body = $request->getBody() ?? '';
11✔
289

290
        if ($body === '') {
11✔
291
            return;
5✔
292
        }
293

294
        // If the token is found in JSON data, we can safely remove it.
295
        try {
296
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
6✔
297
        } catch (JsonException) {
3✔
298
            $json = null;
3✔
299
        }
300

301
        if (is_object($json) && property_exists($json, $tokenName)) {
6✔
302
            unset($json->{$tokenName});
3✔
303
            $request->setBody(json_encode($json));
3✔
304

305
            return;
3✔
306
        }
307

308
        // If the token is found in form-encoded data, we can safely remove it.
309
        parse_str($body, $result);
3✔
310
        unset($result[$tokenName]);
3✔
311
        $request->setBody(http_build_query($result));
3✔
312
    }
313

314
    private function getPostedToken(IncomingRequest $request): ?string
315
    {
316
        $tokenName  = $this->config->tokenName;
47✔
317
        $headerName = $this->config->headerName;
47✔
318

319
        // 1. Check POST data first.
320
        $token = $request->getPost($tokenName);
47✔
321

322
        if ($this->isNonEmptyTokenString($token)) {
47✔
323
            return $token;
16✔
324
        }
325

326
        // 2. Check header data next.
327
        if ($request->hasHeader($headerName)) {
31✔
328
            $token = $request->header($headerName)->getValue();
12✔
329

330
            if ($this->isNonEmptyTokenString($token)) {
12✔
331
                return $token;
11✔
332
            }
333
        }
334

335
        // 3. Finally, check the raw input data for JSON or form-encoded data.
336
        $body = $request->getBody() ?? '';
20✔
337

338
        if ($body === '') {
20✔
339
            return null;
4✔
340
        }
341

342
        // 3a. Check if a JSON payload exists and contains the token.
343
        try {
344
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
16✔
345
        } catch (JsonException) {
8✔
346
            $json = null;
8✔
347
        }
348

349
        if (is_object($json) && property_exists($json, $tokenName)) {
16✔
350
            $token = $json->{$tokenName};
8✔
351

352
            if ($this->isNonEmptyTokenString($token)) {
8✔
353
                return $token;
7✔
354
            }
355
        }
356

357
        // 3b. Check if form-encoded data exists and contains the token.
358
        parse_str($body, $result);
9✔
359
        $token = $result[$tokenName] ?? null;
9✔
360

361
        if ($this->isNonEmptyTokenString($token)) {
9✔
362
            return $token;
5✔
363
        }
364

365
        return null;
4✔
366
    }
367

368
    /**
369
     * @phpstan-assert-if-true non-empty-string $token
370
     */
371
    private function isNonEmptyTokenString(mixed $token): bool
372
    {
373
        return is_string($token) && $token !== '';
47✔
374
    }
375

376
    /**
377
     * Returns the CSRF Token.
378
     */
379
    public function getHash(): ?string
380
    {
381
        return $this->config->tokenRandomize ? $this->randomize($this->hash) : $this->hash;
23✔
382
    }
383

384
    /**
385
     * Randomize hash to avoid BREACH attacks.
386
     *
387
     * @params string $hash CSRF hash
388
     *
389
     * @return string CSRF token
390
     */
391
    protected function randomize(string $hash): string
392
    {
393
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
1✔
394
        $hashBinary = hex2bin($hash);
1✔
395

396
        if ($hashBinary === false) {
1✔
UNCOV
397
            throw new LogicException('$hash is invalid: ' . $hash);
×
398
        }
399

400
        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
1✔
401
    }
402

403
    /**
404
     * Derandomize the token.
405
     *
406
     * @params string $token CSRF token
407
     *
408
     * @return string CSRF hash
409
     *
410
     * @throws InvalidArgumentException "hex2bin(): Hexadecimal input string must have an even length"
411
     */
412
    protected function derandomize(#[SensitiveParameter] string $token): string
413
    {
414
        $key   = substr($token, -static::CSRF_HASH_BYTES * 2);
13✔
415
        $value = substr($token, 0, static::CSRF_HASH_BYTES * 2);
13✔
416

417
        try {
418
            return bin2hex((string) hex2bin($value) ^ (string) hex2bin($key));
13✔
419
        } catch (ErrorException $e) {
1✔
420
            // "hex2bin(): Hexadecimal input string must have an even length"
421
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
1✔
422
        }
423
    }
424

425
    /**
426
     * Returns the CSRF Token Name.
427
     */
428
    public function getTokenName(): string
429
    {
430
        return $this->config->tokenName;
9✔
431
    }
432

433
    /**
434
     * Returns the CSRF Header Name.
435
     */
436
    public function getHeaderName(): string
437
    {
438
        return $this->config->headerName;
3✔
439
    }
440

441
    /**
442
     * Returns the CSRF Cookie Name.
443
     */
444
    public function getCookieName(): string
445
    {
446
        return $this->config->cookieName;
3✔
447
    }
448

449
    /**
450
     * Check if request should be redirect on failure.
451
     */
452
    public function shouldRedirect(): bool
453
    {
454
        return $this->config->redirect;
1✔
455
    }
456

457
    /**
458
     * Sanitize Filename
459
     *
460
     * Tries to sanitize filenames in order to prevent directory traversal attempts
461
     * and other security threats, which is particularly useful for files that
462
     * were supplied via user input.
463
     *
464
     * If it is acceptable for the user input to include relative paths,
465
     * e.g. file/in/some/approved/folder.txt, you can set the second optional
466
     * parameter, $relativePath to TRUE.
467
     *
468
     * @deprecated 4.6.2 Use `sanitize_filename()` instead
469
     *
470
     * @param string $str          Input file name
471
     * @param bool   $relativePath Whether to preserve paths
472
     */
473
    public function sanitizeFilename(string $str, bool $relativePath = false): string
474
    {
475
        helper('security');
1✔
476

477
        return sanitize_filename($str, $relativePath);
1✔
478
    }
479

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

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

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

509
        return $this->hash;
90✔
510
    }
511

512
    private function isHashInCookie(): bool
513
    {
514
        if ($this->hashInCookie === null) {
85✔
515
            return false;
71✔
516
        }
517

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

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

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

534
        $response = service('response');
78✔
535
        $response->setCookie($this->cookie);
78✔
536
    }
537

538
    private function saveHashInSession(): void
539
    {
540
        $this->session->set($this->config->tokenName, $this->hash);
12✔
541
    }
542
}
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

© 2026 Coveralls, Inc