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

Cookie::__toString()   B

Complexity

Conditions 9
Paths 128

Size

Total Lines 36
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

Changes 0
Metric Value
cc 9
eloc 17
c 0
b 0
f 0
nc 128
nop 0
dl 0
loc 36
rs 7.8222
ccs 0
cts 0
cp 0
crap 90
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_map;
16
use function array_shift;
17
use function explode;
18
use function implode;
19
use function in_array;
20
use function preg_match;
21
use function preg_split;
22
use function strtolower;
23
24
/**
25
 * Cookie helps adding Set-Cookie header response in order to set cookie
26
 */
27
final class Cookie
28
{
29
    /**
30
     * Regular Expression used to validate cookie name
31
     * @see https://tools.ietf.org/html/rfc6265#section-4.1.1
32
     * @see https://tools.ietf.org/html/rfc2616#section-2.2
33
     */
34
    private const TOKEN = '/^[a-zA-Z0-9!#$%&\' * +\- .^_`|~]+$/';
35
36
    /**
37
     * Regular expression used to validate cooke value
38
     * @see https://tools.ietf.org/html/rfc6265#section-4.1.1
39
     * @see https://tools.ietf.org/html/rfc2616#section-2.2
40
     */
41
    private const 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 context
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
     * @see $sameSite
49
     */
50
    public const SAME_SITE_LAX = 'Lax';
51
52
    /**
53
     * SameSite policy Strict will prevent the cookie from being sent by the browser in all cross-site browsing context
54
     * regardless of the request method and even when following a regular link.
55
     * E.g. a GET request from https://otherdomain.com to https://yourdomain.com or a user following a link from
56
     * https://otherdomain.com to https://yourdomain.com will not include the cookie.
57
     * @see $sameSite
58
     */
59
    public const SAME_SITE_STRICT = 'Strict';
60
61
    public const SAME_SITE_NONE = 'None';
62
63
    /**
64
     * @var string name of the cookie
65
     */
66
    private string $name;
67
68
    /**
69
     * @var string value of the cookie
70
     */
71
    private string $value;
72
73
    /**
74
     * @var DateTimeInterface|null RFC-1123 date at which the cookie expires.
75
     * @see https://tools.ietf.org/html/rfc6265#section-4.1.1
76
     */
77
    private ?DateTimeInterface $expire = null;
78
79
    /**
80
     * @var int|null maximum age of cookie in seconds
81 11
     */
82
    private ?int $maxAge = null;
83
84
    /**
85 11
     * @var string|null domain of the cookie.
86 1
     */
87
    private ?string $domain = null;
88
89 10
    /**
90 10
     * @var string|null the path on the server in which the cookie will be available on.
91
     */
92
    private ?string $path = null;
93 2
94
    /**
95 2
     * @var bool|null whether cookie should be sent via secure connection
96 2
     */
97 2
    private ?bool $secure = null;
98
99
    /**
100 1
     * @var bool|null whether the cookie should be accessible only through the HTTP protocol.
101
     * By setting this property to true, the cookie will not be accessible by scripting languages,
102 1
     * such as JavaScript, which can effectively help to reduce identity theft through XSS attacks.
103 1
     */
104 1
    private ?bool $httpOnly = null;
105 1
106
    /**
107
     * @var string|null SameSite prevents the browser from sending this cookie along with cross-site requests.
108 1
     * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute for more information about sameSite.
109
     */
110 1
    private ?string $sameSite = null;
111 1
112 1
    public function __construct(string $name, string $value = '', bool $safeDefaults = true)
113
    {
114
        if (!preg_match(self::TOKEN, $name)) {
115 1
            throw new InvalidArgumentException("The cookie name \"$name\" contains invalid characters.");
116
        }
117 1
118 1
        $this->name = $name;
119 1
        $this->setValue($value);
120
121
        if ($safeDefaults) {
122 2
            $this->path = '/';
123
            $this->secure = true;
124 2
            $this->httpOnly = true;
125 2
            $this->sameSite = self::SAME_SITE_LAX;
126 2
        }
127
    }
128
129 2
    public function getName(): string
130
    {
131 2
        return $this->name;
132 2
    }
133 2
134
    public function getValue(): string
135
    {
136 2
        return $this->value;
137
    }
138 2
139 2
    public function isExpired(): bool
140 2
    {
141
        return isset($this->expire) && $this->expire->getTimestamp() < (new DateTimeImmutable())->getTimestamp();
0 ignored issues
show
Bug introduced by
The method getTimestamp() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

141
        return isset($this->expire) && $this->expire->/** @scrutinizer ignore-call */ getTimestamp() < (new DateTimeImmutable())->getTimestamp();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
142
    }
143 3
144
    public function expireAt(DateTimeInterface $dateTime): self
145 3
    {
146 1
        $new = clone $this;
147
        $new->expire = $dateTime;
148
        return $new;
149 2
    }
150 2
151 2
    public function expire(): self
152
    {
153
        return $this->expireAt((new \DateTimeImmutable('-5 years')));
154 9
    }
155
156 9
    public function maxAge(DateInterval $dateInterval): self
157
    {
158 9
        $reference = new DateTimeImmutable();
159 2
        $expireDateTime = $reference->add($dateInterval);
160
161 9
        $new = clone $this;
162 9
        $new->expire = $expireDateTime;
163
        $new->maxAge = $expireDateTime->getTimestamp() - $reference->getTimestamp();
164 9
        return $new;
165 2
    }
166
167 9
    public function expireWhenBrowserIsClosed(): self
168 8
    {
169
        $new = clone $this;
170 9
        $new->expire = null;
171 8
        return $new;
172
    }
173 9
174 8
    public function domain(string $domain): self
175
    {
176
        $new = clone $this;
177 9
        $new->domain = $domain;
178
        return $new;
179
    }
180
181
    public function path(string $path): self
182
    {
183
        // path value is defined as any character except CTLs or ";"
184
        if (preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
185
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
186
        }
187
188
        $new = clone $this;
189
        $new->path = $path;
190
        return $new;
191
    }
192
193
    public function secure(bool $secure): self
194
    {
195
        $new = clone $this;
196
        $new->secure = $secure;
197
        return $new;
198
    }
199
200
    public function httpOnly(bool $httpOnly): self
201
    {
202
        $new = clone $this;
203
        $new->httpOnly = $httpOnly;
204
        return $new;
205
    }
206
207
    public function sameSite(string $sameSite): self
208
    {
209
        if (!in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
210
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
211
        }
212
213
        $new = clone $this;
214
215
        if ($sameSite === self::SAME_SITE_NONE) {
216
            // the secure flag is required for cookies that are marked as 'SameSite=None'
217
            // so that cross-site cookies can only be accessed over HTTPS
218
            // without it cookie will not be available for external access
219
            $new->secure = true;
220
        }
221
222
        $new->sameSite = $sameSite;
223
        return $new;
224
    }
225
226
    private function setValue(string $value): void
227
    {
228
        // @see https://tools.ietf.org/html/rfc6265#section-4.1.1
229
        if (!preg_match(self::OCTET, $value)) {
230
            throw new InvalidArgumentException("The cookie value \"$value\" contains invalid characters.");
231
        }
232
233
        $this->value = $value;
234
    }
235
236
    public function addToResponse(ResponseInterface $response): ResponseInterface
237
    {
238
        return $response->withAddedHeader('Set-Cookie', (string) $this);
239
    }
240
241
    public function __toString(): string
242
    {
243
        $cookieParts = [
244
            $this->name . '=' . $this->value
245
        ];
246
247
        if ($this->expire) {
248
            $cookieParts[] = 'Expires=' . $this->expire->format(DateTimeInterface::RFC7231);
249
        }
250
251
        // max-age must be non-zero digit
252
        if ($this->maxAge && $this->maxAge > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->maxAge of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
253
            $cookieParts[] = 'Max-Age=' . $this->maxAge;
254
        }
255
256
        if ($this->domain) {
257
            $cookieParts[] = 'Domain=' . $this->domain;
258
        }
259
260
        if ($this->path) {
261
            $cookieParts[] = 'Path=' . $this->path;
262
        }
263
264
        if ($this->secure) {
265
            $cookieParts[] = 'Secure';
266
        }
267
268
        if ($this->httpOnly) {
269
            $cookieParts[] = 'HttpOnly';
270
        }
271
272
        if ($this->sameSite) {
273
            $cookieParts[] = 'SameSite=' . $this->sameSite;
274
        }
275
276
        return implode('; ', $cookieParts);
277
    }
278
279
    /**
280
     * Parse 'Set-Cookie' string and build Cookie object.
281
     * Pass only Set-Cookie header value.
282
     *
283
     * @param string $string 'Set-Cookie' header value
284
     * @return self
285
     * @throws Exception
286
     */
287
    public static function fromSetCookieString(string $string): self
288
    {
289
        // array_filter with empty callback is used to filter out all falsy values
290
        $rawAttributes = array_filter(preg_split('~\s*[;]\s*~', $string));
0 ignored issues
show
Bug introduced by
It seems like preg_split('~\s*[;]\s*~', $string) can also be of type false; however, parameter $input of array_filter() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

290
        $rawAttributes = array_filter(/** @scrutinizer ignore-type */ preg_split('~\s*[;]\s*~', $string));
Loading history...
291
292
        $rawAttribute = array_shift($rawAttributes);
293
294
        if (!is_string($rawAttribute)) {
295
            throw new InvalidArgumentException('Cookie string must have at least on attribute');
296
        }
297
298
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
299
300
        $cookie = new self($cookieName, $cookieValue ?? '', false);
301
302
        while ($rawAttribute = array_shift($rawAttributes)) {
303
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
304
            $attributeKey = strtolower($attributeKey);
305
306
            if ($attributeValue === null && ($attributeKey !== 'secure' || $attributeKey !== 'httponly')) {
307
                continue;
308
            }
309
310
            switch (strtolower($attributeKey)) {
311
                case 'expires':
312
                    $cookie = $cookie->expireAt(new DateTimeImmutable($attributeValue));
313
                    break;
314
                case 'max-age':
315
                    $cookie = $cookie->maxAge(new DateInterval('PT' . $attributeValue . 'S'));
316
                    break;
317
                case 'domain':
318
                    $cookie = $cookie->domain($attributeValue);
319
                    break;
320
                case 'path':
321
                    $cookie = $cookie->path($attributeValue);
322
                    break;
323
                case 'secure':
324
                    $cookie = $cookie->secure(true);
325
                    break;
326
                case 'httponly':
327
                    $cookie = $cookie->httpOnly(true);
328
                    break;
329
                case 'samesite':
330
                    $cookie = $cookie->sameSite($attributeValue);
331
                    break;
332
            }
333
        }
334
335
        return $cookie;
336
    }
337
338
    private static function splitCookieAttribute(string $attribute): array
339
    {
340
        $parts = explode('=', $attribute, 2);
341
        $parts[1] = $parts[1] ?? null;
342
343
        return array_map('urldecode', $parts);
344
    }
345
}
346