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

codeigniter4 / CodeIgniter4 / 22274186362

22 Feb 2026 09:06AM UTC coverage: 86.561% (+0.6%) from 85.977%
22274186362

Pull #9962

github

web-flow
Merge 96a632b74 into cd3013ba3
Pull Request #9962: feat: Chunk array method in models

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

66 existing lines in 5 files now uncovered.

22254 of 25709 relevant lines covered (86.56%)

209.53 hits per line

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

97.41
/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

41
    /**
42
     * CSRF hash length in bytes.
43
     */
44
    protected const CSRF_HASH_BYTES = 16;
45

46
    /**
47
     * CSRF hash length in hexadecimal characters.
48
     */
49
    protected const CSRF_HASH_HEX = self::CSRF_HASH_BYTES * 2;
50

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

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

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

76
    /**
77
     * CSRF Cookie Name without Prefix
78
     */
79
    private ?string $rawCookieName = null;
80

81
    private ?SessionInterface $session = null;
82

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

91
    public function __construct(protected SecurityConfig $config)
92
    {
93
        $this->rawCookieName = $config->cookieName;
109✔
94

95
        if ($this->isCsrfCookie()) {
109✔
96
            $this->configureCookie(config(CookieConfig::class));
83✔
97
        } else {
98
            $this->configureSession();
26✔
99
        }
100

101
        $this->hashInCookie = service('request')->getCookie($this->cookieName);
109✔
102

103
        $this->restoreHash();
109✔
104

105
        if ($this->hash === null) {
109✔
106
            $this->generateHash();
70✔
107
        }
108
    }
109

110
    public function verify(RequestInterface $request): static
111
    {
112
        $method = $request->getMethod();
39✔
113

114
        // Protect POST, PUT, DELETE, PATCH requests only
115
        if (! in_array($method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true)) {
39✔
116
            return $this;
3✔
117
        }
118

119
        assert($request instanceof IncomingRequest);
120

121
        $postedToken = $this->getPostedToken($request);
36✔
122

123
        try {
124
            $token = $postedToken !== null && $this->config->tokenRandomize
36✔
125
                ? $this->derandomize($postedToken)
13✔
126
                : $postedToken;
23✔
127
        } catch (InvalidArgumentException) {
5✔
128
            $token = null;
5✔
129
        }
130

131
        if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) {
36✔
132
            throw SecurityException::forDisallowedAction();
14✔
133
        }
134

135
        $this->removeTokenInRequest($request);
22✔
136

137
        if ($this->config->regenerate) {
22✔
138
            $this->generateHash();
18✔
139
        }
140

141
        log_message('info', 'CSRF token verified.');
22✔
142

143
        return $this;
22✔
144
    }
145

146
    public function getHash(): ?string
147
    {
148
        return $this->config->tokenRandomize && isset($this->hash)
23✔
149
            ? $this->randomize($this->hash)
4✔
150
            : $this->hash;
23✔
151
    }
152

153
    public function getTokenName(): string
154
    {
155
        return $this->config->tokenName;
9✔
156
    }
157

158
    public function getHeaderName(): string
159
    {
160
        return $this->config->headerName;
3✔
161
    }
162

163
    public function getCookieName(): string
164
    {
165
        return $this->config->cookieName;
3✔
166
    }
167

168
    public function shouldRedirect(): bool
169
    {
170
        return $this->config->redirect;
1✔
171
    }
172

173
    /**
174
     * @phpstan-assert string $this->hash
175
     */
176
    public function generateHash(): string
177
    {
178
        $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES));
88✔
179

180
        if ($this->isCsrfCookie()) {
88✔
181
            $this->saveHashInCookie();
76✔
182
        } else {
183
            $this->saveHashInSession();
12✔
184
        }
185

186
        return $this->hash;
88✔
187
    }
188

189
    /**
190
     * Randomize hash to avoid BREACH attacks.
191
     */
192
    protected function randomize(string $hash): string
193
    {
194
        $keyBinary  = random_bytes(static::CSRF_HASH_BYTES);
1✔
195
        $hashBinary = hex2bin($hash);
1✔
196

197
        if ($hashBinary === false) {
1✔
UNCOV
198
            throw new LogicException('$hash is invalid: ' . $hash);
×
199
        }
200

201
        return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
1✔
202
    }
203

204
    /**
205
     * Derandomize the token.
206
     *
207
     * @throws InvalidArgumentException
208
     */
209
    protected function derandomize(#[SensitiveParameter] string $token): string
210
    {
211
        // The token should be in the format of `randomizedHash` + `key`,
212
        // where both `randomizedHash` and `key` are hex strings of length CSRF_HASH_HEX.
213
        if (strlen($token) !== self::CSRF_HASH_HEX * 2) {
13✔
214
            throw new InvalidArgumentException('Invalid CSRF token.');
5✔
215
        }
216

217
        $keyBinary  = hex2bin(substr($token, -self::CSRF_HASH_HEX));
8✔
218
        $hashBinary = hex2bin(substr($token, 0, self::CSRF_HASH_HEX));
8✔
219

220
        if ($hashBinary === false || $keyBinary === false) {
8✔
UNCOV
221
            throw new InvalidArgumentException('Invalid CSRF token.');
×
222
        }
223

224
        return bin2hex($hashBinary ^ $keyBinary);
8✔
225
    }
226

227
    private function isCsrfCookie(): bool
228
    {
229
        return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE;
109✔
230
    }
231

232
    /**
233
     * @phpstan-assert SessionInterface $this->session
234
     */
235
    private function configureSession(): void
236
    {
237
        $this->session = service('session');
26✔
238
    }
239

240
    private function configureCookie(CookieConfig $cookie): void
241
    {
242
        $this->cookieName = $cookie->prefix . $this->rawCookieName;
83✔
243

244
        Cookie::setDefaults($cookie);
83✔
245
    }
246

247
    /**
248
     * Remove token in POST, JSON, or form-encoded data to prevent it from being accidentally leaked.
249
     */
250
    private function removeTokenInRequest(IncomingRequest $request): void
251
    {
252
        $superglobals = service('superglobals');
22✔
253
        $tokenName    = $this->config->tokenName;
22✔
254

255
        // If the token is found in POST data, we can safely remove it.
256
        if (is_string($superglobals->post($tokenName))) {
22✔
257
            $superglobals->unsetPost($tokenName);
11✔
258
            $request->setGlobal('post', $superglobals->getPostArray());
11✔
259

260
            return;
11✔
261
        }
262

263
        $body = $request->getBody() ?? '';
11✔
264

265
        if ($body === '') {
11✔
266
            return;
5✔
267
        }
268

269
        // If the token is found in JSON data, we can safely remove it.
270
        try {
271
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
6✔
272
        } catch (JsonException) {
3✔
273
            $json = null;
3✔
274
        }
275

276
        if (is_object($json) && property_exists($json, $tokenName)) {
6✔
277
            unset($json->{$tokenName});
3✔
278
            $request->setBody(json_encode($json));
3✔
279

280
            return;
3✔
281
        }
282

283
        // If the token is found in form-encoded data, we can safely remove it.
284
        parse_str($body, $result);
3✔
285
        unset($result[$tokenName]);
3✔
286
        $request->setBody(http_build_query($result));
3✔
287
    }
288

289
    private function getPostedToken(IncomingRequest $request): ?string
290
    {
291
        $tokenName  = $this->config->tokenName;
47✔
292
        $headerName = $this->config->headerName;
47✔
293

294
        // 1. Check POST data first.
295
        $token = $request->getPost($tokenName);
47✔
296

297
        if ($this->isNonEmptyTokenString($token)) {
47✔
298
            return $token;
16✔
299
        }
300

301
        // 2. Check header data next.
302
        if ($request->hasHeader($headerName)) {
31✔
303
            $token = $request->header($headerName)->getValue();
12✔
304

305
            if ($this->isNonEmptyTokenString($token)) {
12✔
306
                return $token;
11✔
307
            }
308
        }
309

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

313
        if ($body === '') {
20✔
314
            return null;
4✔
315
        }
316

317
        // 3a. Check if a JSON payload exists and contains the token.
318
        try {
319
            $json = json_decode($body, flags: JSON_THROW_ON_ERROR);
16✔
320
        } catch (JsonException) {
8✔
321
            $json = null;
8✔
322
        }
323

324
        if (is_object($json) && property_exists($json, $tokenName)) {
16✔
325
            $token = $json->{$tokenName};
8✔
326

327
            if ($this->isNonEmptyTokenString($token)) {
8✔
328
                return $token;
7✔
329
            }
330
        }
331

332
        // 3b. Check if form-encoded data exists and contains the token.
333
        parse_str($body, $result);
9✔
334
        $token = $result[$tokenName] ?? null;
9✔
335

336
        if ($this->isNonEmptyTokenString($token)) {
9✔
337
            return $token;
5✔
338
        }
339

340
        return null;
4✔
341
    }
342

343
    /**
344
     * @phpstan-assert-if-true non-empty-string $token
345
     */
346
    private function isNonEmptyTokenString(mixed $token): bool
347
    {
348
        return is_string($token) && $token !== '';
47✔
349
    }
350

351
    /**
352
     * Restore hash from Session or Cookie
353
     */
354
    private function restoreHash(): void
355
    {
356
        if ($this->isCsrfCookie()) {
109✔
357
            $this->hash = $this->isHashInCookie() ? $this->hashInCookie : null;
83✔
358

359
            return;
83✔
360
        }
361

362
        $tokenName = $this->config->tokenName;
26✔
363

364
        if ($this->session instanceof SessionInterface && $this->session->has($tokenName)) {
26✔
365
            $this->hash = $this->session->get($tokenName);
26✔
366
        }
367
    }
368

369
    private function isHashInCookie(): bool
370
    {
371
        if ($this->hashInCookie === null) {
83✔
372
            return false;
70✔
373
        }
374

375
        if (strlen($this->hashInCookie) !== self::CSRF_HASH_HEX) {
15✔
UNCOV
376
            return false;
×
377
        }
378

379
        return ctype_xdigit($this->hashInCookie);
15✔
380
    }
381

382
    private function saveHashInCookie(): void
383
    {
384
        $expires = $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires;
76✔
385

386
        $cookie = new Cookie(
76✔
387
            $this->rawCookieName,
76✔
388
            $this->hash,
76✔
389
            compact('expires'),
76✔
390
        );
76✔
391

392
        service('response')->setCookie($cookie);
76✔
393

394
        // For backward compatibility, we also set the cookie value to $this->cookie property.
395
        // @todo v4.8.0 Remove $this->cookie property and its usages.
396
        $this->cookie = $cookie;
76✔
397
    }
398

399
    private function saveHashInSession(): void
400
    {
401
        assert($this->session instanceof SessionInterface);
402
        $this->session->set($this->config->tokenName, $this->hash);
12✔
403
    }
404
}
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