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

codeigniter4 / CodeIgniter4 / 26155821722

20 May 2026 10:09AM UTC coverage: 88.471% (+0.009%) from 88.462%
26155821722

Pull #10221

github

web-flow
Merge a41b5365c into 0be36657e
Pull Request #10221: feat: add Fetch Metadata CSRF protection

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

3 existing lines in 1 file now uncovered.

24165 of 27314 relevant lines covered (88.47%)

219.72 hits per line

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

97.84
/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;
133✔
97

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

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

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

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

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

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

122
        assert($request instanceof IncomingRequest);
123

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

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

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

131
            return $this;
6✔
132
        }
133

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

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

140
        return $this;
32✔
141
    }
142

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

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

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

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

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

165
        log_message('info', 'CSRF token verified.');
32✔
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
    /**
196
     * @phpstan-assert string $this->hash
197
     */
198
    public function generateHash(): string
199
    {
200
        $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES));
109✔
201

202
        if ($this->isCsrfCookie()) {
109✔
203
            $this->saveHashInCookie();
95✔
204
        } else {
205
            $this->saveHashInSession();
14✔
206
        }
207

208
        return $this->hash;
109✔
209
    }
210

211
    /**
212
     * Randomize hash to avoid BREACH attacks.
213
     */
214
    protected function randomize(string $hash): string
215
    {
216
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
1✔
217
        $hashBinary = hex2bin($hash);
1✔
218

219
        if ($hashBinary === false) {
1✔
UNCOV
220
            throw new LogicException('$hash is invalid: ' . $hash);
×
221
        }
222

223
        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
1✔
224
    }
225

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

239
        $keyBinary  = hex2bin(substr($token, -self::CSRF_HASH_HEX));
8✔
240
        $hashBinary = hex2bin(substr($token, 0, self::CSRF_HASH_HEX));
8✔
241

242
        if ($hashBinary === false || $keyBinary === false) {
8✔
UNCOV
243
            throw new InvalidArgumentException('Invalid CSRF token.');
×
244
        }
245

246
        return bin2hex($hashBinary ^ $keyBinary);
8✔
247
    }
248

249
    private function isCsrfCookie(): bool
250
    {
251
        return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE;
133✔
252
    }
253

254
    /**
255
     * @return self::FETCH_METADATA_*
256
     */
257
    private function fetchMetadataDecision(IncomingRequest $request): string
258
    {
259
        if (! ($this->config->csrfUseFetchMetadata ?? false)) { // @phpstan-ignore nullCoalesce.initializedProperty
56✔
260
            return self::FETCH_METADATA_FALLBACK;
3✔
261
        }
262

263
        $fetchSite = strtolower($request->getHeaderLine('Sec-Fetch-Site'));
53✔
264

265
        if ($fetchSite === 'same-origin') {
53✔
266
            return self::FETCH_METADATA_ALLOW;
5✔
267
        }
268

269
        if ($fetchSite === 'cross-site') {
48✔
270
            return self::FETCH_METADATA_REJECT;
3✔
271
        }
272

273
        if ($fetchSite === 'same-site') {
45✔
274
            return $this->config->csrfAllowSameSite ?? false // @phpstan-ignore nullCoalesce.initializedProperty
2✔
275
                ? self::FETCH_METADATA_ALLOW
1✔
276
                : self::FETCH_METADATA_REJECT;
2✔
277
        }
278

279
        return self::FETCH_METADATA_FALLBACK;
43✔
280
    }
281

282
    /**
283
     * @phpstan-assert SessionInterface $this->session
284
     */
285
    private function configureSession(): void
286
    {
287
        $this->session = service('session');
28✔
288
    }
289

290
    private function configureCookie(CookieConfig $cookie): void
291
    {
292
        $this->cookieName = $cookie->prefix . $this->rawCookieName;
105✔
293

294
        Cookie::setDefaults($cookie);
105✔
295
    }
296

297
    /**
298
     * Remove token in POST, JSON, or form-encoded data to prevent it from being accidentally leaked.
299
     */
300
    private function removeTokenInRequest(IncomingRequest $request): void
301
    {
302
        $superglobals = service('superglobals');
38✔
303
        $tokenName    = $this->config->tokenName;
38✔
304

305
        // If the token is found in POST data, we can safely remove it.
306
        if (is_string($superglobals->post($tokenName))) {
38✔
307
            $superglobals->unsetPost($tokenName);
18✔
308
            $request->setGlobal('post', $superglobals->getPostArray());
18✔
309

310
            return;
18✔
311
        }
312

313
        $body = $request->getBody() ?? '';
20✔
314

315
        if ($body === '') {
20✔
316
            return;
9✔
317
        }
318

319
        // If the token is found in JSON data, we can safely remove it.
320
        try {
321
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
11✔
322
        } catch (JsonException) {
4✔
323
            $json = null;
4✔
324
        }
325

326
        if (is_object($json)) {
11✔
327
            if (property_exists($json, $tokenName)) {
7✔
328
                unset($json->{$tokenName});
5✔
329
                $request->setBody(json_encode($json));
5✔
330
            }
331

332
            return;
7✔
333
        }
334

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

338
        if (! array_key_exists($tokenName, $result)) {
4✔
339
            return;
1✔
340
        }
341

342
        unset($result[$tokenName]);
3✔
343
        $request->setBody(http_build_query($result));
3✔
344
    }
345

346
    private function getPostedToken(IncomingRequest $request): ?string
347
    {
348
        $tokenName  = $this->config->tokenName;
57✔
349
        $headerName = $this->config->headerName;
57✔
350

351
        // 1. Check POST data first.
352
        $token = $request->getPost($tokenName);
57✔
353

354
        if ($this->isNonEmptyTokenString($token)) {
57✔
355
            return $token;
22✔
356
        }
357

358
        // 2. Check header data next.
359
        if ($request->hasHeader($headerName)) {
35✔
360
            $token = $request->header($headerName)->getValue();
16✔
361

362
            if ($this->isNonEmptyTokenString($token)) {
16✔
363
                return $token;
15✔
364
            }
365
        }
366

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

370
        if ($body === '') {
20✔
371
            return null;
4✔
372
        }
373

374
        // 3a. Check if a JSON payload exists and contains the token.
375
        try {
376
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
16✔
377
        } catch (JsonException) {
8✔
378
            $json = null;
8✔
379
        }
380

381
        if (is_object($json) && property_exists($json, $tokenName)) {
16✔
382
            $token = $json->{$tokenName};
8✔
383

384
            if ($this->isNonEmptyTokenString($token)) {
8✔
385
                return $token;
7✔
386
            }
387
        }
388

389
        // 3b. Check if form-encoded data exists and contains the token.
390
        parse_str($body, $result);
9✔
391
        $token = $result[$tokenName] ?? null;
9✔
392

393
        if ($this->isNonEmptyTokenString($token)) {
9✔
394
            return $token;
5✔
395
        }
396

397
        return null;
4✔
398
    }
399

400
    /**
401
     * @phpstan-assert-if-true non-empty-string $token
402
     */
403
    private function isNonEmptyTokenString(mixed $token): bool
404
    {
405
        return is_string($token) && $token !== '';
57✔
406
    }
407

408
    /**
409
     * Restore hash from Session or Cookie
410
     */
411
    private function restoreHash(): void
412
    {
413
        if ($this->isCsrfCookie()) {
133✔
414
            $this->hash = $this->isHashInCookie() ? $this->hashInCookie : null;
105✔
415

416
            return;
105✔
417
        }
418

419
        $tokenName = $this->config->tokenName;
28✔
420

421
        if ($this->session instanceof SessionInterface && $this->session->has($tokenName)) {
28✔
422
            $this->hash = $this->session->get($tokenName);
28✔
423
        }
424
    }
425

426
    private function isHashInCookie(): bool
427
    {
428
        if ($this->hashInCookie === null) {
105✔
429
            return false;
81✔
430
        }
431

432
        if (strlen($this->hashInCookie) !== self::CSRF_HASH_HEX) {
26✔
UNCOV
433
            return false;
×
434
        }
435

436
        return ctype_xdigit($this->hashInCookie);
26✔
437
    }
438

439
    private function saveHashInCookie(): void
440
    {
441
        $expires = $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires;
95✔
442

443
        $cookie = new Cookie(
95✔
444
            $this->rawCookieName,
95✔
445
            $this->hash,
95✔
446
            compact('expires'),
95✔
447
        );
95✔
448

449
        service('response')->setCookie($cookie);
95✔
450

451
        // For backward compatibility, we also set the cookie value to $this->cookie property.
452
        // @todo v4.8.0 Remove $this->cookie property and its usages.
453
        $this->cookie = $cookie;
95✔
454
    }
455

456
    private function saveHashInSession(): void
457
    {
458
        assert($this->session instanceof SessionInterface);
459
        $this->session->set($this->config->tokenName, $this->hash);
14✔
460
    }
461
}
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