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

codeigniter4 / CodeIgniter4 / 26717872894

31 May 2026 04:19PM UTC coverage: 88.556% (+0.08%) from 88.475%
26717872894

Pull #10242

github

web-flow
Merge 47dc3835f into 2ef1571f5
Pull Request #10242: feat: add immutable URI query variable helpers

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

155 existing lines in 9 files now uncovered.

24267 of 27403 relevant lines covered (88.56%)

223.65 hits per line

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

97.86
/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\SessionInterface;
25
use Config\Cookie as CookieConfig;
26
use Config\Security as SecurityConfig;
27
use JsonException;
28
use SensitiveParameter;
29

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

44
    /**
45
     * CSRF hash length in bytes.
46
     */
47
    protected const CSRF_HASH_BYTES = 16;
48

49
    /**
50
     * CSRF hash length in hexadecimal characters.
51
     */
52
    protected const CSRF_HASH_HEX = self::CSRF_HASH_BYTES * 2;
53

54
    /**
55
     * CSRF Hash (without randomization)
56
     *
57
     * Random hash for Cross Site Request Forgery protection.
58
     *
59
     * @var string|null
60
     */
61
    protected $hash;
62

63
    /**
64
     * @var Cookie
65
     *
66
     * @deprecated v4.8.0 Use service('response')->getCookie() instead.
67
     */
68
    protected $cookie;
69

70
    /**
71
     * CSRF Cookie Name (with Prefix)
72
     *
73
     * Cookie name for Cross Site Request Forgery protection.
74
     *
75
     * @var string
76
     */
77
    protected $cookieName = 'csrf_cookie_name';
78

79
    /**
80
     * CSRF Cookie Name without Prefix
81
     */
82
    private ?string $rawCookieName = null;
83

84
    private ?SessionInterface $session = null;
85

86
    /**
87
     * CSRF Hash in Request Cookie
88
     *
89
     * The cookie value is always CSRF hash (without randomization) even if
90
     * $tokenRandomize is true.
91
     */
92
    private ?string $hashInCookie = null;
93

94
    public function __construct(protected SecurityConfig $config)
95
    {
96
        $this->rawCookieName = $config->cookieName;
136✔
97

98
        if ($this->isCsrfCookie()) {
136✔
99
            $this->configureCookie(config(CookieConfig::class));
108✔
100
        } else {
101
            $this->configureSession();
28✔
102
        }
103

104
        $this->hashInCookie = service('request')->getCookie($this->cookieName);
136✔
105

106
        $this->restoreHash();
136✔
107

108
        if ($this->hash === null) {
136✔
109
            $this->generateHash();
81✔
110
        }
111
    }
112

113
    public function verify(RequestInterface $request): static
114
    {
115
        $method = $request->getMethod();
62✔
116

117
        // Protect POST, PUT, DELETE, PATCH requests only
118
        if (! in_array($method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true)) {
62✔
119
            return $this;
3✔
120
        }
121

122
        assert($request instanceof IncomingRequest);
123

124
        $decision = $this->fetchMetadataDecision($request);
59✔
125

126
        if ($decision === self::FETCH_METADATA_ALLOW) {
59✔
127
            $this->removeTokenInRequest($request);
5✔
128

129
            log_message('info', 'CSRF Fetch Metadata verified.');
5✔
130

131
            return $this;
5✔
132
        }
133

134
        if ($decision === self::FETCH_METADATA_REJECT) {
54✔
135
            throw SecurityException::forDisallowedAction();
4✔
136
        }
137

138
        $this->verifyToken($request);
50✔
139

140
        return $this;
34✔
141
    }
142

143
    private function verifyToken(IncomingRequest $request): void
144
    {
145
        $postedToken = $this->getPostedToken($request);
50✔
146

147
        try {
148
            $token = $postedToken !== null && $this->config->tokenRandomize
50✔
149
                ? $this->derandomize($postedToken)
13✔
150
                : $postedToken;
37✔
151
        } catch (InvalidArgumentException) {
5✔
152
            $token = null;
5✔
153
        }
154

155
        if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) {
50✔
156
            throw SecurityException::forDisallowedAction();
16✔
157
        }
158

159
        $this->removeTokenInRequest($request);
34✔
160

161
        if ($this->config->regenerate) {
34✔
162
            $this->generateHash();
30✔
163
        }
164

165
        log_message('info', 'CSRF token verified.');
34✔
166
    }
167

168
    public function getHash(): ?string
169
    {
170
        return $this->config->tokenRandomize && isset($this->hash)
24✔
171
            ? $this->randomize($this->hash)
4✔
172
            : $this->hash;
24✔
173
    }
174

175
    public function getTokenName(): string
176
    {
177
        return $this->config->tokenName;
9✔
178
    }
179

180
    public function getHeaderName(): string
181
    {
182
        return $this->config->headerName;
3✔
183
    }
184

185
    public function getCookieName(): string
186
    {
187
        return $this->config->cookieName;
3✔
188
    }
189

190
    public function shouldRedirect(): bool
191
    {
192
        return $this->config->redirect;
3✔
193
    }
194

195
    public function shouldUseFetchMetadata(): bool
196
    {
197
        return $this->config->csrfFetchMetadata ?? false; // @phpstan-ignore nullCoalesce.initializedProperty
60✔
198
    }
199

200
    /**
201
     * @phpstan-assert string $this->hash
202
     */
203
    public function generateHash(): string
204
    {
205
        $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES));
111✔
206

207
        if ($this->isCsrfCookie()) {
111✔
208
            $this->saveHashInCookie();
97✔
209
        } else {
210
            $this->saveHashInSession();
14✔
211
        }
212

213
        return $this->hash;
111✔
214
    }
215

216
    /**
217
     * Randomize hash to avoid BREACH attacks.
218
     */
219
    protected function randomize(string $hash): string
220
    {
221
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
1✔
222
        $hashBinary = hex2bin($hash);
1✔
223

224
        if ($hashBinary === false) {
1✔
UNCOV
225
            throw new LogicException('$hash is invalid: ' . $hash);
×
226
        }
227

228
        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
1✔
229
    }
230

231
    /**
232
     * Derandomize the token.
233
     *
234
     * @throws InvalidArgumentException
235
     */
236
    protected function derandomize(#[SensitiveParameter] string $token): string
237
    {
238
        // The token should be in the format of `randomizedHash` + `key`,
239
        // where both `randomizedHash` and `key` are hex strings of length CSRF_HASH_HEX.
240
        if (strlen($token) !== self::CSRF_HASH_HEX * 2) {
13✔
241
            throw new InvalidArgumentException('Invalid CSRF token.');
5✔
242
        }
243

244
        $keyBinary  = hex2bin(substr($token, -self::CSRF_HASH_HEX));
8✔
245
        $hashBinary = hex2bin(substr($token, 0, self::CSRF_HASH_HEX));
8✔
246

247
        if ($hashBinary === false || $keyBinary === false) {
8✔
UNCOV
248
            throw new InvalidArgumentException('Invalid CSRF token.');
×
249
        }
250

251
        return bin2hex($hashBinary ^ $keyBinary);
8✔
252
    }
253

254
    private function isCsrfCookie(): bool
255
    {
256
        return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE;
136✔
257
    }
258

259
    /**
260
     * @return self::FETCH_METADATA_*
261
     */
262
    private function fetchMetadataDecision(IncomingRequest $request): string
263
    {
264
        if (! $this->shouldUseFetchMetadata()) {
59✔
265
            return self::FETCH_METADATA_FALLBACK;
3✔
266
        }
267

268
        $fetchSite = strtolower($request->getHeaderLine('Sec-Fetch-Site'));
56✔
269

270
        if ($fetchSite === 'same-origin') {
56✔
271
            return self::FETCH_METADATA_ALLOW;
5✔
272
        }
273

274
        if ($fetchSite === 'cross-site') {
51✔
275
            return self::FETCH_METADATA_REJECT;
3✔
276
        }
277

278
        if ($fetchSite === 'same-site') {
48✔
279
            return ($this->config->csrfFetchMetadataRejectSameSite ?? false) // @phpstan-ignore nullCoalesce.initializedProperty
4✔
280
                ? self::FETCH_METADATA_REJECT
1✔
281
                : self::FETCH_METADATA_FALLBACK;
4✔
282
        }
283

284
        return self::FETCH_METADATA_FALLBACK;
44✔
285
    }
286

287
    /**
288
     * @phpstan-assert SessionInterface $this->session
289
     */
290
    private function configureSession(): void
291
    {
292
        $this->session = service('session');
28✔
293
    }
294

295
    private function configureCookie(CookieConfig $cookie): void
296
    {
297
        $this->cookieName = $cookie->prefix . $this->rawCookieName;
108✔
298

299
        Cookie::setDefaults($cookie);
108✔
300
    }
301

302
    /**
303
     * Remove token in POST, JSON, or form-encoded data to prevent it from being accidentally leaked.
304
     */
305
    private function removeTokenInRequest(IncomingRequest $request): void
306
    {
307
        $superglobals = service('superglobals');
39✔
308
        $tokenName    = $this->config->tokenName;
39✔
309

310
        // If the token is found in POST data, we can safely remove it.
311
        if (is_string($superglobals->post($tokenName))) {
39✔
312
            $superglobals->unsetPost($tokenName);
20✔
313
            $request->setGlobal('post', $superglobals->getPostArray());
20✔
314

315
            return;
20✔
316
        }
317

318
        $body = $request->getBody() ?? '';
19✔
319

320
        if ($body === '') {
19✔
321
            return;
8✔
322
        }
323

324
        // If the token is found in JSON data, we can safely remove it.
325
        try {
326
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
11✔
327
        } catch (JsonException) {
4✔
328
            $json = null;
4✔
329
        }
330

331
        if (is_object($json)) {
11✔
332
            if (property_exists($json, $tokenName)) {
7✔
333
                unset($json->{$tokenName});
5✔
334
                $request->setBody(json_encode($json));
5✔
335
            }
336

337
            return;
7✔
338
        }
339

340
        // If the token is found in form-encoded data, we can safely remove it.
341
        parse_str($body, $result);
4✔
342

343
        if (! array_key_exists($tokenName, $result)) {
4✔
344
            return;
1✔
345
        }
346

347
        unset($result[$tokenName]);
3✔
348
        $request->setBody(http_build_query($result));
3✔
349
    }
350

351
    private function getPostedToken(IncomingRequest $request): ?string
352
    {
353
        $tokenName  = $this->config->tokenName;
61✔
354
        $headerName = $this->config->headerName;
61✔
355

356
        // 1. Check POST data first.
357
        $token = $request->getPost($tokenName);
61✔
358

359
        if ($this->isNonEmptyTokenString($token)) {
61✔
360
            return $token;
25✔
361
        }
362

363
        // 2. Check header data next.
364
        if ($request->hasHeader($headerName)) {
36✔
365
            $token = $request->header($headerName)->getValue();
16✔
366

367
            if ($this->isNonEmptyTokenString($token)) {
16✔
368
                return $token;
15✔
369
            }
370
        }
371

372
        // 3. Finally, check the raw input data for JSON or form-encoded data.
373
        $body = $request->getBody() ?? '';
21✔
374

375
        if ($body === '') {
21✔
376
            return null;
5✔
377
        }
378

379
        // 3a. Check if a JSON payload exists and contains the token.
380
        try {
381
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
16✔
382
        } catch (JsonException) {
8✔
383
            $json = null;
8✔
384
        }
385

386
        if (is_object($json) && property_exists($json, $tokenName)) {
16✔
387
            $token = $json->{$tokenName};
8✔
388

389
            if ($this->isNonEmptyTokenString($token)) {
8✔
390
                return $token;
7✔
391
            }
392
        }
393

394
        // 3b. Check if form-encoded data exists and contains the token.
395
        parse_str($body, $result);
9✔
396
        $token = $result[$tokenName] ?? null;
9✔
397

398
        if ($this->isNonEmptyTokenString($token)) {
9✔
399
            return $token;
5✔
400
        }
401

402
        return null;
4✔
403
    }
404

405
    /**
406
     * @phpstan-assert-if-true non-empty-string $token
407
     */
408
    private function isNonEmptyTokenString(mixed $token): bool
409
    {
410
        return is_string($token) && $token !== '';
61✔
411
    }
412

413
    /**
414
     * Restore hash from Session or Cookie
415
     */
416
    private function restoreHash(): void
417
    {
418
        if ($this->isCsrfCookie()) {
136✔
419
            $this->hash = $this->isHashInCookie() ? $this->hashInCookie : null;
108✔
420

421
            return;
108✔
422
        }
423

424
        $tokenName = $this->config->tokenName;
28✔
425

426
        if ($this->session instanceof SessionInterface && $this->session->has($tokenName)) {
28✔
427
            $this->hash = $this->session->get($tokenName);
28✔
428
        }
429
    }
430

431
    private function isHashInCookie(): bool
432
    {
433
        if ($this->hashInCookie === null) {
108✔
434
            return false;
81✔
435
        }
436

437
        if (strlen($this->hashInCookie) !== self::CSRF_HASH_HEX) {
29✔
UNCOV
438
            return false;
×
439
        }
440

441
        return ctype_xdigit($this->hashInCookie);
29✔
442
    }
443

444
    private function saveHashInCookie(): void
445
    {
446
        $expires = $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires;
97✔
447

448
        $cookie = new Cookie(
97✔
449
            $this->rawCookieName,
97✔
450
            $this->hash,
97✔
451
            compact('expires'),
97✔
452
        );
97✔
453

454
        service('response')->setCookie($cookie);
97✔
455

456
        // For backward compatibility, we also set the cookie value to $this->cookie property.
457
        // @todo v4.8.0 Remove $this->cookie property and its usages.
458
        $this->cookie = $cookie;
97✔
459
    }
460

461
    private function saveHashInSession(): void
462
    {
463
        assert($this->session instanceof SessionInterface);
464
        $this->session->set($this->config->tokenName, $this->hash);
14✔
465
    }
466
}
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