Passed
Pull Request — master (#244)
by
unknown
07:48
created

Cookie::setValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 8
ccs 0
cts 0
cp 0
crap 6
rs 10
1
<?php
2
declare(strict_types=1);
3
4
namespace Yiisoft\Yii\Web;
5
6
use DateInterval;
7
use DateTimeImmutable;
8
use DateTimeInterface;
9
use Exception;
10
use InvalidArgumentException;
11
use Psr\Http\Message\ResponseInterface;
12
13
use function array_filter;
14
use function array_map;
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
23
/**
24
 * Cookie helps adding Set-Cookie header response in order to set cookie
25
 */
26
final class Cookie
27
{
28
    // @see https://tools.ietf.org/html/rfc6265#section-4
29
    // @see https://tools.ietf.org/html/rfc2616#section-2.2
30
    private const TOKEN = '/^[a-zA-Z0-9!#$%&\' * +\- .^_`|~]+$/';
31
    private const OCTET='/^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/';
32
33
    /**
34
     * SameSite policy Lax will prevent the cookie from being sent by the browser in all cross-site browsing context
35
     * during CSRF-prone request methods (e.g. POST, PUT, PATCH etc).
36
     * E.g. a POST request from https://otherdomain.com to https://yourdomain.com will not include the cookie, however a GET request will.
37
     * When a user follows a link from https://otherdomain.com to https://yourdomain.com it will include the cookie
38
     * @see $sameSite
39
     */
40
    public const SAME_SITE_LAX = 'Lax';
41
42
    /**
43
     * SameSite policy Strict will prevent the cookie from being sent by the browser in all cross-site browsing context
44
     * regardless of the request method and even when following a regular link.
45
     * E.g. a GET request from https://otherdomain.com to https://yourdomain.com or a user following a link from
46
     * https://otherdomain.com to https://yourdomain.com will not include the cookie.
47
     * @see $sameSite
48
     */
49
    public const SAME_SITE_STRICT = 'Strict';
50
51
    public const SAME_SITE_NONE = 'None';
52
53
    /**
54
     * @var string name of the cookie
55
     */
56
    private string $name;
57
58
    /**
59
     * @var string value of the cookie
60
     */
61
    private string $value = '';
62
63
    /**
64
     * @var string|null RFC-1123 date at which the cookie expires.
65
     * @see https://tools.ietf.org/html/rfc6265#section-4.1.1
66
     */
67
    private ?string $expire = null;
68
69
    /**
70
     * @var string|null domain of the cookie.
71
     */
72
    private ?string $domain = null;
73
74
    /**
75
     * @var string|null the path on the server in which the cookie will be available on.
76
     */
77
    private ?string $path = null;
78
79
    /**
80
     * @var bool|null whether cookie should be sent via secure connection
81 11
     */
82
    private ?bool $secure = null;
83
84
    /**
85 11
     * @var bool|null whether the cookie should be accessible only through the HTTP protocol.
86 1
     * By setting this property to true, the cookie will not be accessible by scripting languages,
87
     * such as JavaScript, which can effectively help to reduce identity theft through XSS attacks.
88
     */
89 10
    private ?bool $httpOnly = null;
90 10
91
    /**
92
     * @var string|null SameSite prevents the browser from sending this cookie along with cross-site requests.
93 2
     * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute for more information about sameSite.
94
     */
95 2
    private ?string $sameSite = null;
96 2
97 2
    public function __construct(string $name, string $value = '', bool $safeDefaults = true)
98
    {
99
        if (!preg_match(self::TOKEN, $name)) {
100 1
            throw new InvalidArgumentException("The cookie name \"$name\" contains invalid characters.");
101
        }
102 1
103 1
        $this->name = $name;
104 1
        $this->setValue($value);
105 1
106
        if ($safeDefaults) {
107
            $this->path = '/';
108 1
            $this->secure = true;
109
            $this->httpOnly = true;
110 1
            $this->sameSite = self::SAME_SITE_LAX;
111 1
        }
112 1
    }
113
114
    public function getName(): string
115 1
    {
116
        return $this->name;
117 1
    }
118 1
119 1
    public function getValue(): string
120
    {
121
        return $this->value;
122 2
    }
123
124 2
    public function expireAt(DateTimeInterface $dateTime): self
125 2
    {
126 2
        $new = clone $this;
127
        $new->expire = $dateTime->format('D, d M Y H:i:s T');
128
        return $new;
129 2
    }
130
131 2
    public function validFor(DateInterval $dateInterval): self
132 2
    {
133 2
        $expireDateTime = (new DateTimeImmutable())->add($dateInterval);
134
        return $this->expireAt($expireDateTime);
135
    }
136 2
137
    public function expireWhenBrowserIsClosed(): self
138 2
    {
139 2
        $new = clone $this;
140 2
        $new->expire = null;
141
        return $new;
142
    }
143 3
144
    public function domain(string $domain): self
145 3
    {
146 1
        $new = clone $this;
147
        $new->domain = $domain;
148
        return $new;
149 2
    }
150 2
151 2
    public function path(string $path): self
152
    {
153
        // path value is defined as any character except CTLs or ";"
154 9
        if (preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
155
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
156 9
        }
157
158 9
        $new = clone $this;
159 2
        $new->path = $path;
160
        return $new;
161 9
    }
162 9
163
    public function secure(bool $secure): self
164 9
    {
165 2
        $new = clone $this;
166
        $new->secure = $secure;
167 9
        return $new;
168 8
    }
169
170 9
    public function httpOnly(bool $httpOnly): self
171 8
    {
172
        $new = clone $this;
173 9
        $new->httpOnly = $httpOnly;
174 8
        return $new;
175
    }
176
177 9
    public function sameSite(string $sameSite): self
178
    {
179
        if (!in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
180
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
181
        }
182
183
        if ($sameSite === self::SAME_SITE_NONE) {
184
            // the secure flag is required for cookies that are marked as 'SameSite=None'
185
            $this->secure = true;
186
        }
187
188
        $new = clone $this;
189
        $new->sameSite = $sameSite;
190
        return $new;
191
    }
192
193
    private function setValue(string $value): void
194
    {
195
        // @see https://tools.ietf.org/html/rfc6265#section-4.1.1
196
        if (!preg_match(self::OCTET, $value)) {
197
            throw new InvalidArgumentException("The cookie value \"$value\" contains invalid characters.");
198
        }
199
200
        $this->value = $value;
201
    }
202
203
    public function addToResponse(ResponseInterface $response): ResponseInterface
204
    {
205
        return $response->withAddedHeader('Set-Cookie', (string) $this);
206
    }
207
208
    public function __toString(): string
209
    {
210
        $cookieParts = [
211
            $this->name . '=' . $this->value
212
        ];
213
214
        if ($this->expire) {
215
            $cookieParts[] = 'Expires=' . $this->expire;
216
        }
217
218
        if ($this->domain) {
219
            $cookieParts[] = 'Domain=' . $this->domain;
220
        }
221
222
        if ($this->path) {
223
            $cookieParts[] = 'Path=' . $this->path;
224
        }
225
226
        if ($this->secure) {
227
            $cookieParts[] = 'Secure';
228
        }
229
230
        if ($this->httpOnly) {
231
            $cookieParts[] = 'HttpOnly';
232
        }
233
234
        if ($this->sameSite) {
235
            $cookieParts[] = 'SameSite=' . $this->sameSite;
236
        }
237
238
        return implode('; ', $cookieParts);
239
    }
240
241
    /**
242
     * Parse 'Set-Cookie' string and build Cookie object.
243
     * Pass only Set-Cookie header value.
244
     *
245
     * @param string $string 'Set-Cookie' header value
246
     * @return self
247
     * @throws Exception
248
     */
249
    public static function fromSetCookieString(string $string): self
250
    {
251
        // array_filter with empty callback is used to filter out all falsy values
252
        $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

252
        $rawAttributes = array_filter(/** @scrutinizer ignore-type */ preg_split('~\s*[;]\s*~', $string));
Loading history...
253
254
        $rawAttribute = array_shift($rawAttributes);
255
256
        if (!is_string($rawAttribute)) {
257
            throw new InvalidArgumentException('Cookie string must have at least on attribute');
258
        }
259
260
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
261
262
        $cookie = new self($cookieName, $cookieValue ?? '', false);
263
264
        while ($rawAttribute = array_shift($rawAttributes)) {
265
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
266
            $attributeKey = strtolower($attributeKey);
267
268
            if ($attributeValue === null && ($attributeKey !== 'secure' || $attributeKey !== 'httponly')) {
269
                continue;
270
            }
271
272
            switch (strtolower($attributeKey)) {
273
                case 'expires':
274
                    $cookie = $cookie->expireAt(new DateTimeImmutable($attributeValue));
275
                    break;
276
                case 'max-age':
277
                    $cookie = $cookie->validFor(new DateInterval('PT' . $attributeValue . 'S'));
278
                    break;
279
                case 'domain':
280
                    $cookie = $cookie->domain($attributeValue);
281
                    break;
282
                case 'path':
283
                    $cookie = $cookie->path($attributeValue);
284
                    break;
285
                case 'secure':
286
                    $cookie = $cookie->secure(true);
287
                    break;
288
                case 'httponly':
289
                    $cookie = $cookie->httpOnly(true);
290
                    break;
291
                case 'samesite':
292
                    $cookie = $cookie->sameSite($attributeValue);
293
                    break;
294
            }
295
        }
296
297
        return $cookie;
298
    }
299
300
    private static function splitCookieAttribute(string $attribute): array
301
    {
302
        $parts = explode('=', $attribute, 2);
303
        $parts[1] = $parts[1] ?? null;
304
305
        return array_map('urldecode', $parts);
306
    }
307
}
308