Passed
Pull Request — master (#244)
by
unknown
02:13
created

Cookie::getDomain()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
ccs 0
cts 0
cp 0
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Web;
6
7
use DateInterval;
8
use DateTimeImmutable;
9
use DateTimeInterface;
10
use Exception;
11
use InvalidArgumentException;
12
use Psr\Http\Message\ResponseInterface;
13
14
use function array_filter;
15
use function array_shift;
16
use function explode;
17
use function implode;
18
use function in_array;
19
use function preg_match;
20
use function preg_split;
21
use function strtolower;
22
use function time;
23
24
/**
25
 * Represents a cookie and also helps adding Set-Cookie header to response in order to set a cookie.
26
 */
27
final class Cookie
28
{
29
    /**
30
     * Regular Expression used to validate cookie name
31
     * @link https://tools.ietf.org/html/rfc6265#section-4.1.1
32
     * @link https://tools.ietf.org/html/rfc2616#section-2.2
33
     */
34
    private const PATTERN_TOKEN = '/^[a-zA-Z0-9!#$%&\' * +\- .^_`|~]+$/';
35
36
    /**
37
     * Regular expression used to validate cooke value
38
     * @link https://tools.ietf.org/html/rfc6265#section-4.1.1
39
     * @link https://tools.ietf.org/html/rfc2616#section-2.2
40
     */
41
    private const PATTERN_OCTET='/^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/';
42
43
    /**
44
     * SameSite policy `Lax` will prevent the cookie from being sent by the browser in all cross-site browsing contexts
45
     * during CSRF-prone request methods (e.g. POST, PUT, PATCH etc).
46
     * E.g. a POST request from https://otherdomain.com to https://yourdomain.com will not include the cookie, however a GET request will.
47
     * When a user follows a link from https://otherdomain.com to https://yourdomain.com it will include the cookie.
48
     * This is the default value in modern browsers.
49
     * @see $sameSite
50
     */
51
    public const SAME_SITE_LAX = 'Lax';
52
53
    /**
54
     * SameSite policy `Strict` will prevent the cookie from being sent by the browser in all cross-site browsing contexts
55
     * regardless of the request method and even when following a regular link.
56
     * E.g. a GET request from https://otherdomain.com to https://yourdomain.com or a user following a link from
57
     * https://otherdomain.com to https://yourdomain.com will not include the cookie.
58
     * @see $sameSite
59
     */
60
    public const SAME_SITE_STRICT = 'Strict';
61
62
    /**
63
     * SameSite policy `None` cookies will be sent in all contexts, i.e. sending cross-origin is allowed.
64
     * `None` requires the `Secure` attribute in latest browser versions.
65
     * @see $sameSite
66
     */
67
    public const SAME_SITE_NONE = 'None';
68
69
    /**
70
     * @var string name of the cookie.
71
     * A cookie name can be any US-ASCII characters, except control characters, spaces, or tabs.
72
     * It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }
73
     */
74
    private string $name;
75
76
    /**
77
     * @var string value of the cookie.
78
     * A cookie value can include any US-ASCII characters excluding control characters, whitespaces,
79
     * double quotes, comma, semicolon, and backslash.
80
     * If you wish to store arbitrary data in a value, you should encode that data.
81 11
     * Value will be decoded when parsed from response.
82
     * @see urlencode()
83
     */
84
    private string $value;
85 11
86 1
    /**
87
     * @var DateTimeInterface|null The maximum lifetime of the cookie.
88
     * If unspecified, the cookie becomes a session cookie, which will be removed
89 10
     * when the client shuts down.
90 10
     * @link https://tools.ietf.org/html/rfc6265#section-4.1.1
91
     * @link https://tools.ietf.org/html/rfc1123#page-55
92
     */
93 2
    private ?DateTimeInterface $expires = null;
94
95 2
    /**
96 2
     * @var string|null host/domain to which the cookie will be sent.
97 2
     * If omitted, client will default to the host of the current URL, not including subdomains.
98
     * Multiple host/domain values are not allowed, but if a domain is specified,
99
     * then subdomains are always included.
100 1
     */
101
    private ?string $domain = null;
102 1
103 1
    /**
104 1
     * @var string|null the path on the server in which the cookie will be available on.
105 1
     * A cookie path can include any US-ASCII characters excluding control characters and semicolon
106
     */
107
    private ?string $path = null;
108 1
109
    /**
110 1
     * @var bool|null whether cookie should be sent via secure connection.
111 1
     * A secure cookie is only sent to the server when a request is made with the https: scheme.
112 1
     */
113
    private ?bool $secure = null;
114
115 1
    /**
116
     * @var bool|null whether the cookie should be accessible only through the HTTP protocol.
117 1
     * By setting this property to true, the cookie will not be accessible by scripting languages,
118 1
     * such as JavaScript, which can effectively help to mitigate attacks against cross-site scripting (XSS).
119 1
     */
120
    private ?bool $httpOnly = null;
121
122 2
    /**
123
     * @var string|null asserts that a cookie must not be sent with cross-origin requests.
124 2
     * This provides some protection against cross-site request forgery attacks (CSRF)
125 2
     * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute for more information about sameSite.
126 2
     */
127
    private ?string $sameSite = null;
128
129 2
    /**
130
     * Cookie constructor.
131 2
     *
132 2
     * @param string $name The name of the cookie
133 2
     * @param string $value The value of of the cookie
134
     * @param DateTimeInterface|null $expires The time the cookie expires
135
     * @param string|null $domain The path on the server in which cookie will be available on
136 2
     * @param string|null $path The host/domain that the cookie is available to
137
     * @param bool|null $secure Whether the client should send back the cookie only over HTTPS connection
138 2
     * @param bool|null $httpOnly Whether the cookie should be accessible only through the HTTP protocol
139 2
     * @param string|null $sameSite Whether the cookie should be available for cross-site requests
140 2
     *
141
     * @throws InvalidArgumentException when one or more arguments are not valid
142
     */
143 3
    public function __construct(
144
        string $name,
145 3
        string $value = '',
146 1
        ?DateTimeInterface $expires = null,
147
        ?string $domain = null,
148
        ?string $path = '/',
149 2
        ?bool $secure = true,
150 2
        ?bool $httpOnly = true,
151 2
        ?string $sameSite = self::SAME_SITE_LAX
152
    ) {
153
        if (!preg_match(self::PATTERN_TOKEN, $name)) {
154 9
            throw new InvalidArgumentException("The cookie name \"$name\" contains invalid characters or is empty.");
155
        }
156 9
157
        $this->name = $name;
158 9
        $this->setValue($value);
159 2
        $this->expires = $expires !== null ? clone $expires : null;
160
        $this->domain = $domain;
161 9
        $this->setPath($path);
162 9
        $this->secure = $secure;
163
        $this->httpOnly = $httpOnly;
164 9
        $this->setSameSite($sameSite);
165 2
    }
166
167 9
    /**
168 8
     * Gets the name of the cookie.
169
     *
170 9
     * @return string
171 8
     */
172
    public function getName(): string
173 9
    {
174 8
        return $this->name;
175
    }
176
177 9
    /**
178
     * Creates a cookie copy with a new value.
179
     *
180
     * @param $value string value of the cookie.
181
     * @return static
182
     * @see $value for more information.
183
     */
184
    public function withValue(string $value): self
185
    {
186
        $new = clone $this;
187
        $new->setValue($value);
188
        return $new;
189
    }
190
191
    /**
192
     * Gets the value of the cookie.
193
     *
194
     * @return string
195
     */
196
    public function getValue(): string
197
    {
198
        return $this->value;
199
    }
200
201
    private function setValue(string $value): void
202
    {
203
        // @link https://tools.ietf.org/html/rfc6265#section-4.1.1
204
        if (!preg_match(self::PATTERN_OCTET, $value)) {
205
            throw new InvalidArgumentException("The cookie value \"$value\" contains invalid characters.");
206
        }
207
208
        $this->value = $value;
209
    }
210
211
    /**
212
     * Creates a cookie copy with a new time the cookie expires.
213
     *
214
     * @param DateTimeInterface $dateTime
215
     * @return static
216
     * @see $expires for more inforemation.
217
     */
218
    public function withExpires(DateTimeInterface $dateTime): self
219
    {
220
        $new = clone $this;
221
        // Defensively clone the object to prevent further change
222
        $new->expires = clone $dateTime;
223
        return $new;
224
    }
225
226
    /**
227
     * Gets the expiry of the cookie.
228
     *
229
     * @return DateTimeImmutable|null
230
     */
231
    public function getExpires(): ?DateTimeImmutable
232
    {
233
        if ($this->expires === null) {
234
            return null;
235
        }
236
237
        // Can be replaced with DateTimeImmutable::createFromInterface in PHP 8
238
        // returns null on `setTimestamp()` failure
239
        return (new DateTimeImmutable())->setTimestamp($this->expires->getTimestamp()) ?: null;
240
    }
241
242
    /**
243
     * Indicates whether the cookie is expired.
244
     * The cookie is expired when it has outdated `Expires`, or
245
     * zero or negative `Max-Age` attributes
246
     *
247
     * @return bool whether the cookie is expired
248
     */
249
    public function isExpired(): bool
250
    {
251
        return $this->expires !== null && $this->expires->getTimestamp() < time();
252
    }
253
254
    /**
255
     * Creates a cookie copy with a new lifetime set.
256
     * If zero or negative interval is passed, the cookie will expire immediately.
257
     *
258
     * @param DateInterval $interval interval until the cookie expires.
259
     * @return static
260
     */
261
    public function withMaxAge(DateInterval $interval): self
262
    {
263
        $new = clone $this;
264
        $new->expires = (new DateTimeImmutable())->add($interval);
265
        return $new;
266
    }
267
268
    /**
269
     * Returns modified cookie that will expire immediately.
270
     * @return static
271
     */
272
    public function expire(): self
273
    {
274
        $new = clone $this;
275
        $new->expires = new DateTimeImmutable('-1 year');
276
        return $new;
277
    }
278
279
    /**
280
     * Will remove the expiration from the cookie which will convert the cookie
281
     * to session cookie, which will expire as soon as the browser is closed.
282
     *
283
     * @return static
284
     */
285
    public function expireWhenBrowserIsClosed(): self
286
    {
287
        $new = clone $this;
288
        $new->expires = null;
289
        return $new;
290
    }
291
292
293
    /**
294
     * Creates a cookie copy with a new domain set.
295
     *
296
     * @param string $domain
297
     * @return static
298
     */
299
    public function withDomain(string $domain): self
300
    {
301
        $new = clone $this;
302
        $new->domain = $domain;
303
        return $new;
304
    }
305
306
    /**
307
     * Gets the domain of the cookie.
308
     *
309
     * @return string|null
310
     */
311
    public function getDomain(): ?string
312
    {
313
        return $this->domain;
314
    }
315
316
    /**
317
     * Creates a cookie copy with a new path set.
318
     *
319
     * @param string $path to be set for the cookie
320
     * @return static
321
     * @see $path for more information.
322
     */
323
    public function withPath(string $path): self
324
    {
325
        $new = clone $this;
326
        $new->setPath($path);
327
        return $new;
328
    }
329
330
    private function setPath(?string $path): void
331
    {
332
        if ($path !== null && preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
333
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
334
        }
335
336
        $this->path = $path;
337
    }
338
339
    /**
340
     * Gets the path of the cookie
341
     *
342
     * @return string|null
343
     */
344
    public function getPath(): ?string
345
    {
346
        return $this->path;
347
    }
348
349
    /**
350
     * Creates a cookie copy by making it secure or insecure.
351
     *
352
     * @param bool $secure whether the cookie must be secure.
353
     * @return static
354
     */
355
    public function withSecure(bool $secure = true): self
356
    {
357
        $new = clone $this;
358
        $new->secure = $secure;
359
        return $new;
360
    }
361
362
    /**
363
     * Whether the cookie is secure.
364
     *
365
     * @return bool
366
     */
367
    public function isSecure(): bool
368
    {
369
        return $this->secure ?? false;
370
    }
371
372
    /**
373
     * Creates a cookie copy that would be accessible only through the HTTP protocol.
374
     *
375
     * @param bool $httpOnly
376
     * @return static
377
     */
378
    public function withHttpOnly(bool $httpOnly = true): self
379
    {
380
        $new = clone $this;
381
        $new->httpOnly = $httpOnly;
382
        return $new;
383
    }
384
385
    /**
386
     * Whether the cookie can be accessed only through the HTTP protocol.
387
     *
388
     * @return bool
389
     */
390
    public function isHttpOnly(): bool
391
    {
392
        return $this->httpOnly ?? false;
393
    }
394
395
    /**
396
     * Creates a cookie copy with SameSite attribute.
397
     *
398
     * @param string $sameSite
399
     * @return static
400
     */
401
    public function withSameSite(string $sameSite): self
402
    {
403
        $new = clone $this;
404
        $new->setSameSite($sameSite);
405
        return $new;
406
    }
407
408
    private function setSameSite(?string $sameSite): void
409
    {
410
        if ($sameSite !== null
411
            && !in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
412
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
413
        }
414
415
        if ($sameSite === self::SAME_SITE_NONE) {
416
            // the secure flag is required for cookies that are marked as 'SameSite=None'
417
            // so that cross-site cookies can only be accessed over HTTPS
418
            // without it cookie will not be available for external access
419
            $this->secure = true;
420
        }
421
422
        $this->sameSite = $sameSite;
423
    }
424
425
    /**
426
     * Gets the SameSite attribute
427
     *
428
     * @return string|null
429
     */
430
    public function getSameSite(): ?string
431
    {
432
        return $this->sameSite;
433
    }
434
435
    /**
436
     * Adds the cookie to the response and returns it.
437
     *
438
     * @param ResponseInterface $response
439
     * @return ResponseInterface response with added cookie.
440
     */
441
    public function addToResponse(ResponseInterface $response): ResponseInterface
442
    {
443
        return $response->withAddedHeader('Set-Cookie', (string) $this);
444
    }
445
446
    /**
447
     * Returns the cookie as a string.
448
     *
449
     * @return string The cookie
450
     */
451
    public function __toString(): string
452
    {
453
        $cookieParts = [
454
            $this->name . '=' . $this->value
455
        ];
456
457
        if ($this->expires !== null) {
458
            $cookieParts[] = 'Expires=' . $this->expires->format(DateTimeInterface::RFC7231);
459
            $cookieParts[] = 'Max-Age=' . ($this->expires->getTimestamp() - time());
460
        }
461
462
        if ($this->domain !== null) {
463
            $cookieParts[] = 'Domain=' . $this->domain;
464
        }
465
466
        if ($this->path !== null) {
467
            $cookieParts[] = 'Path=' . $this->path;
468
        }
469
470
        if ($this->secure) {
471
            $cookieParts[] = 'Secure';
472
        }
473
474
        if ($this->httpOnly) {
475
            $cookieParts[] = 'HttpOnly';
476
        }
477
478
        if ($this->sameSite !== null) {
479
            $cookieParts[] = 'SameSite=' . $this->sameSite;
480
        }
481
482
        return implode('; ', $cookieParts);
483
    }
484
485
    /**
486
     * Parse `Set-Cookie` string and build Cookie object.
487
     *
488
     * @param string $string `Set-Cookie` header value to parse
489
     * @return static
490
     * @throws Exception
491
     */
492
    public static function fromCookieString(string $string): self
493
    {
494
        $rawAttributes = preg_split('~\s*[;]\s*~', $string);
495
        if ($rawAttributes === false) {
496
            throw new InvalidArgumentException('Failed to parse Set-Cookie string ' . $string);
497
        }
498
        // array_filter with empty callback is used to filter out all falsy values
499
        $rawAttributes = array_filter($rawAttributes);
500
501
        $rawAttribute = array_shift($rawAttributes);
502
503
        if (!is_string($rawAttribute)) {
504
            throw new InvalidArgumentException('Cookie string must have at least name');
505
        }
506
507
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
508
509
        $params = [
510
            'name' => $cookieName,
511
            'value' => $cookieValue !== null ? urldecode($cookieValue) : '',
512
        ];
513
514
        while ($rawAttribute = array_shift($rawAttributes)) {
515
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
516
            $attributeKey = strtolower($attributeKey);
517
518
            if ($attributeValue === null && !in_array($attributeKey, ['secure', 'httponly'])) {
519
                continue;
520
            }
521
522
            switch (strtolower($attributeKey)) {
523
                case 'expires':
524
                    $params['expires'] = new DateTimeImmutable($attributeValue);
525
                    break;
526
                case 'max-age':
527
                    $params['expires'] = (new DateTimeImmutable())->setTimestamp(time() + (int) $attributeValue);
528
                    break;
529
                case 'domain':
530
                    $params['domain'] = $attributeValue;
531
                    break;
532
                case 'path':
533
                    $params['path'] = $attributeValue;
534
                    break;
535
                case 'secure':
536
                    $params['secure'] = true;
537
                    break;
538
                case 'httponly':
539
                    $params['httpOnly'] = true;
540
                    break;
541
                case 'samesite':
542
                    $params['sameSite'] = $attributeValue;
543
                    break;
544
            }
545
        }
546
547
        return new self(
548
            $params['name'],
549
            $params['value'],
550
            $params['expires'] ?? null,
551
            $params['domain'] ?? null,
552
            $params['path'] ?? null,
553
            $params['secure'] ?? null,
554
            $params['httpOnly'] ?? null,
555
            $params['sameSite'] ?? null
556
        );
557
    }
558
559
    private static function splitCookieAttribute(string $attribute): array
560
    {
561
        $parts = explode('=', $attribute, 2);
562
        $parts[1] = $parts[1] ?? null;
563
564
        return $parts;
565
    }
566
}
567