|
1 <?php |
|
2 /** |
|
3 * Cookie storage object |
|
4 * |
|
5 * @package Requests\Cookies |
|
6 */ |
|
7 |
|
8 namespace WpOrg\Requests; |
|
9 |
|
10 use WpOrg\Requests\Exception\InvalidArgument; |
|
11 use WpOrg\Requests\Iri; |
|
12 use WpOrg\Requests\Response\Headers; |
|
13 use WpOrg\Requests\Utility\CaseInsensitiveDictionary; |
|
14 use WpOrg\Requests\Utility\InputValidator; |
|
15 |
|
16 /** |
|
17 * Cookie storage object |
|
18 * |
|
19 * @package Requests\Cookies |
|
20 */ |
|
21 class Cookie { |
|
22 /** |
|
23 * Cookie name. |
|
24 * |
|
25 * @var string |
|
26 */ |
|
27 public $name; |
|
28 |
|
29 /** |
|
30 * Cookie value. |
|
31 * |
|
32 * @var string |
|
33 */ |
|
34 public $value; |
|
35 |
|
36 /** |
|
37 * Cookie attributes |
|
38 * |
|
39 * Valid keys are `'path'`, `'domain'`, `'expires'`, `'max-age'`, `'secure'` and |
|
40 * `'httponly'`. |
|
41 * |
|
42 * @var \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array Array-like object |
|
43 */ |
|
44 public $attributes = []; |
|
45 |
|
46 /** |
|
47 * Cookie flags |
|
48 * |
|
49 * Valid keys are `'creation'`, `'last-access'`, `'persistent'` and `'host-only'`. |
|
50 * |
|
51 * @var array |
|
52 */ |
|
53 public $flags = []; |
|
54 |
|
55 /** |
|
56 * Reference time for relative calculations |
|
57 * |
|
58 * This is used in place of `time()` when calculating Max-Age expiration and |
|
59 * checking time validity. |
|
60 * |
|
61 * @var int |
|
62 */ |
|
63 public $reference_time = 0; |
|
64 |
|
65 /** |
|
66 * Create a new cookie object |
|
67 * |
|
68 * @param string $name The name of the cookie. |
|
69 * @param string $value The value for the cookie. |
|
70 * @param array|\WpOrg\Requests\Utility\CaseInsensitiveDictionary $attributes Associative array of attribute data |
|
71 * @param array $flags The flags for the cookie. |
|
72 * Valid keys are `'creation'`, `'last-access'`, |
|
73 * `'persistent'` and `'host-only'`. |
|
74 * @param int|null $reference_time Reference time for relative calculations. |
|
75 * |
|
76 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string. |
|
77 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $value argument is not a string. |
|
78 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $attributes argument is not an array or iterable object with array access. |
|
79 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $flags argument is not an array. |
|
80 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $reference_time argument is not an integer or null. |
|
81 */ |
|
82 public function __construct($name, $value, $attributes = [], $flags = [], $reference_time = null) { |
|
83 if (is_string($name) === false) { |
|
84 throw InvalidArgument::create(1, '$name', 'string', gettype($name)); |
|
85 } |
|
86 |
|
87 if (is_string($value) === false) { |
|
88 throw InvalidArgument::create(2, '$value', 'string', gettype($value)); |
|
89 } |
|
90 |
|
91 if (InputValidator::has_array_access($attributes) === false || InputValidator::is_iterable($attributes) === false) { |
|
92 throw InvalidArgument::create(3, '$attributes', 'array|ArrayAccess&Traversable', gettype($attributes)); |
|
93 } |
|
94 |
|
95 if (is_array($flags) === false) { |
|
96 throw InvalidArgument::create(4, '$flags', 'array', gettype($flags)); |
|
97 } |
|
98 |
|
99 if ($reference_time !== null && is_int($reference_time) === false) { |
|
100 throw InvalidArgument::create(5, '$reference_time', 'integer|null', gettype($reference_time)); |
|
101 } |
|
102 |
|
103 $this->name = $name; |
|
104 $this->value = $value; |
|
105 $this->attributes = $attributes; |
|
106 $default_flags = [ |
|
107 'creation' => time(), |
|
108 'last-access' => time(), |
|
109 'persistent' => false, |
|
110 'host-only' => true, |
|
111 ]; |
|
112 $this->flags = array_merge($default_flags, $flags); |
|
113 |
|
114 $this->reference_time = time(); |
|
115 if ($reference_time !== null) { |
|
116 $this->reference_time = $reference_time; |
|
117 } |
|
118 |
|
119 $this->normalize(); |
|
120 } |
|
121 |
|
122 /** |
|
123 * Get the cookie value |
|
124 * |
|
125 * Attributes and other data can be accessed via methods. |
|
126 */ |
|
127 public function __toString() { |
|
128 return $this->value; |
|
129 } |
|
130 |
|
131 /** |
|
132 * Check if a cookie is expired. |
|
133 * |
|
134 * Checks the age against $this->reference_time to determine if the cookie |
|
135 * is expired. |
|
136 * |
|
137 * @return boolean True if expired, false if time is valid. |
|
138 */ |
|
139 public function is_expired() { |
|
140 // RFC6265, s. 4.1.2.2: |
|
141 // If a cookie has both the Max-Age and the Expires attribute, the Max- |
|
142 // Age attribute has precedence and controls the expiration date of the |
|
143 // cookie. |
|
144 if (isset($this->attributes['max-age'])) { |
|
145 $max_age = $this->attributes['max-age']; |
|
146 return $max_age < $this->reference_time; |
|
147 } |
|
148 |
|
149 if (isset($this->attributes['expires'])) { |
|
150 $expires = $this->attributes['expires']; |
|
151 return $expires < $this->reference_time; |
|
152 } |
|
153 |
|
154 return false; |
|
155 } |
|
156 |
|
157 /** |
|
158 * Check if a cookie is valid for a given URI |
|
159 * |
|
160 * @param \WpOrg\Requests\Iri $uri URI to check |
|
161 * @return boolean Whether the cookie is valid for the given URI |
|
162 */ |
|
163 public function uri_matches(Iri $uri) { |
|
164 if (!$this->domain_matches($uri->host)) { |
|
165 return false; |
|
166 } |
|
167 |
|
168 if (!$this->path_matches($uri->path)) { |
|
169 return false; |
|
170 } |
|
171 |
|
172 return empty($this->attributes['secure']) || $uri->scheme === 'https'; |
|
173 } |
|
174 |
|
175 /** |
|
176 * Check if a cookie is valid for a given domain |
|
177 * |
|
178 * @param string $domain Domain to check |
|
179 * @return boolean Whether the cookie is valid for the given domain |
|
180 */ |
|
181 public function domain_matches($domain) { |
|
182 if (is_string($domain) === false) { |
|
183 return false; |
|
184 } |
|
185 |
|
186 if (!isset($this->attributes['domain'])) { |
|
187 // Cookies created manually; cookies created by Requests will set |
|
188 // the domain to the requested domain |
|
189 return true; |
|
190 } |
|
191 |
|
192 $cookie_domain = $this->attributes['domain']; |
|
193 if ($cookie_domain === $domain) { |
|
194 // The cookie domain and the passed domain are identical. |
|
195 return true; |
|
196 } |
|
197 |
|
198 // If the cookie is marked as host-only and we don't have an exact |
|
199 // match, reject the cookie |
|
200 if ($this->flags['host-only'] === true) { |
|
201 return false; |
|
202 } |
|
203 |
|
204 if (strlen($domain) <= strlen($cookie_domain)) { |
|
205 // For obvious reasons, the cookie domain cannot be a suffix if the passed domain |
|
206 // is shorter than the cookie domain |
|
207 return false; |
|
208 } |
|
209 |
|
210 if (substr($domain, -1 * strlen($cookie_domain)) !== $cookie_domain) { |
|
211 // The cookie domain should be a suffix of the passed domain. |
|
212 return false; |
|
213 } |
|
214 |
|
215 $prefix = substr($domain, 0, strlen($domain) - strlen($cookie_domain)); |
|
216 if (substr($prefix, -1) !== '.') { |
|
217 // The last character of the passed domain that is not included in the |
|
218 // domain string should be a %x2E (".") character. |
|
219 return false; |
|
220 } |
|
221 |
|
222 // The passed domain should be a host name (i.e., not an IP address). |
|
223 return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $domain); |
|
224 } |
|
225 |
|
226 /** |
|
227 * Check if a cookie is valid for a given path |
|
228 * |
|
229 * From the path-match check in RFC 6265 section 5.1.4 |
|
230 * |
|
231 * @param string $request_path Path to check |
|
232 * @return boolean Whether the cookie is valid for the given path |
|
233 */ |
|
234 public function path_matches($request_path) { |
|
235 if (empty($request_path)) { |
|
236 // Normalize empty path to root |
|
237 $request_path = '/'; |
|
238 } |
|
239 |
|
240 if (!isset($this->attributes['path'])) { |
|
241 // Cookies created manually; cookies created by Requests will set |
|
242 // the path to the requested path |
|
243 return true; |
|
244 } |
|
245 |
|
246 if (is_scalar($request_path) === false) { |
|
247 return false; |
|
248 } |
|
249 |
|
250 $cookie_path = $this->attributes['path']; |
|
251 |
|
252 if ($cookie_path === $request_path) { |
|
253 // The cookie-path and the request-path are identical. |
|
254 return true; |
|
255 } |
|
256 |
|
257 if (strlen($request_path) > strlen($cookie_path) && substr($request_path, 0, strlen($cookie_path)) === $cookie_path) { |
|
258 if (substr($cookie_path, -1) === '/') { |
|
259 // The cookie-path is a prefix of the request-path, and the last |
|
260 // character of the cookie-path is %x2F ("/"). |
|
261 return true; |
|
262 } |
|
263 |
|
264 if (substr($request_path, strlen($cookie_path), 1) === '/') { |
|
265 // The cookie-path is a prefix of the request-path, and the |
|
266 // first character of the request-path that is not included in |
|
267 // the cookie-path is a %x2F ("/") character. |
|
268 return true; |
|
269 } |
|
270 } |
|
271 |
|
272 return false; |
|
273 } |
|
274 |
|
275 /** |
|
276 * Normalize cookie and attributes |
|
277 * |
|
278 * @return boolean Whether the cookie was successfully normalized |
|
279 */ |
|
280 public function normalize() { |
|
281 foreach ($this->attributes as $key => $value) { |
|
282 $orig_value = $value; |
|
283 |
|
284 if (is_string($key)) { |
|
285 $value = $this->normalize_attribute($key, $value); |
|
286 } |
|
287 |
|
288 if ($value === null) { |
|
289 unset($this->attributes[$key]); |
|
290 continue; |
|
291 } |
|
292 |
|
293 if ($value !== $orig_value) { |
|
294 $this->attributes[$key] = $value; |
|
295 } |
|
296 } |
|
297 |
|
298 return true; |
|
299 } |
|
300 |
|
301 /** |
|
302 * Parse an individual cookie attribute |
|
303 * |
|
304 * Handles parsing individual attributes from the cookie values. |
|
305 * |
|
306 * @param string $name Attribute name |
|
307 * @param string|int|bool $value Attribute value (string/integer value, or true if empty/flag) |
|
308 * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped) |
|
309 */ |
|
310 protected function normalize_attribute($name, $value) { |
|
311 switch (strtolower($name)) { |
|
312 case 'expires': |
|
313 // Expiration parsing, as per RFC 6265 section 5.2.1 |
|
314 if (is_int($value)) { |
|
315 return $value; |
|
316 } |
|
317 |
|
318 $expiry_time = strtotime($value); |
|
319 if ($expiry_time === false) { |
|
320 return null; |
|
321 } |
|
322 |
|
323 return $expiry_time; |
|
324 |
|
325 case 'max-age': |
|
326 // Expiration parsing, as per RFC 6265 section 5.2.2 |
|
327 if (is_int($value)) { |
|
328 return $value; |
|
329 } |
|
330 |
|
331 // Check that we have a valid age |
|
332 if (!preg_match('/^-?\d+$/', $value)) { |
|
333 return null; |
|
334 } |
|
335 |
|
336 $delta_seconds = (int) $value; |
|
337 if ($delta_seconds <= 0) { |
|
338 $expiry_time = 0; |
|
339 } else { |
|
340 $expiry_time = $this->reference_time + $delta_seconds; |
|
341 } |
|
342 |
|
343 return $expiry_time; |
|
344 |
|
345 case 'domain': |
|
346 // Domains are not required as per RFC 6265 section 5.2.3 |
|
347 if (empty($value)) { |
|
348 return null; |
|
349 } |
|
350 |
|
351 // Domain normalization, as per RFC 6265 section 5.2.3 |
|
352 if ($value[0] === '.') { |
|
353 $value = substr($value, 1); |
|
354 } |
|
355 |
|
356 return $value; |
|
357 |
|
358 default: |
|
359 return $value; |
|
360 } |
|
361 } |
|
362 |
|
363 /** |
|
364 * Format a cookie for a Cookie header |
|
365 * |
|
366 * This is used when sending cookies to a server. |
|
367 * |
|
368 * @return string Cookie formatted for Cookie header |
|
369 */ |
|
370 public function format_for_header() { |
|
371 return sprintf('%s=%s', $this->name, $this->value); |
|
372 } |
|
373 |
|
374 /** |
|
375 * Format a cookie for a Set-Cookie header |
|
376 * |
|
377 * This is used when sending cookies to clients. This isn't really |
|
378 * applicable to client-side usage, but might be handy for debugging. |
|
379 * |
|
380 * @return string Cookie formatted for Set-Cookie header |
|
381 */ |
|
382 public function format_for_set_cookie() { |
|
383 $header_value = $this->format_for_header(); |
|
384 if (!empty($this->attributes)) { |
|
385 $parts = []; |
|
386 foreach ($this->attributes as $key => $value) { |
|
387 // Ignore non-associative attributes |
|
388 if (is_numeric($key)) { |
|
389 $parts[] = $value; |
|
390 } else { |
|
391 $parts[] = sprintf('%s=%s', $key, $value); |
|
392 } |
|
393 } |
|
394 |
|
395 $header_value .= '; ' . implode('; ', $parts); |
|
396 } |
|
397 |
|
398 return $header_value; |
|
399 } |
|
400 |
|
401 /** |
|
402 * Parse a cookie string into a cookie object |
|
403 * |
|
404 * Based on Mozilla's parsing code in Firefox and related projects, which |
|
405 * is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265 |
|
406 * specifies some of this handling, but not in a thorough manner. |
|
407 * |
|
408 * @param string $cookie_header Cookie header value (from a Set-Cookie header) |
|
409 * @param string $name |
|
410 * @param int|null $reference_time |
|
411 * @return \WpOrg\Requests\Cookie Parsed cookie object |
|
412 * |
|
413 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cookie_header argument is not a string. |
|
414 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string. |
|
415 */ |
|
416 public static function parse($cookie_header, $name = '', $reference_time = null) { |
|
417 if (is_string($cookie_header) === false) { |
|
418 throw InvalidArgument::create(1, '$cookie_header', 'string', gettype($cookie_header)); |
|
419 } |
|
420 |
|
421 if (is_string($name) === false) { |
|
422 throw InvalidArgument::create(2, '$name', 'string', gettype($name)); |
|
423 } |
|
424 |
|
425 $parts = explode(';', $cookie_header); |
|
426 $kvparts = array_shift($parts); |
|
427 |
|
428 if (!empty($name)) { |
|
429 $value = $cookie_header; |
|
430 } elseif (strpos($kvparts, '=') === false) { |
|
431 // Some sites might only have a value without the equals separator. |
|
432 // Deviate from RFC 6265 and pretend it was actually a blank name |
|
433 // (`=foo`) |
|
434 // |
|
435 // https://bugzilla.mozilla.org/show_bug.cgi?id=169091 |
|
436 $name = ''; |
|
437 $value = $kvparts; |
|
438 } else { |
|
439 list($name, $value) = explode('=', $kvparts, 2); |
|
440 } |
|
441 |
|
442 $name = trim($name); |
|
443 $value = trim($value); |
|
444 |
|
445 // Attribute keys are handled case-insensitively |
|
446 $attributes = new CaseInsensitiveDictionary(); |
|
447 |
|
448 if (!empty($parts)) { |
|
449 foreach ($parts as $part) { |
|
450 if (strpos($part, '=') === false) { |
|
451 $part_key = $part; |
|
452 $part_value = true; |
|
453 } else { |
|
454 list($part_key, $part_value) = explode('=', $part, 2); |
|
455 $part_value = trim($part_value); |
|
456 } |
|
457 |
|
458 $part_key = trim($part_key); |
|
459 $attributes[$part_key] = $part_value; |
|
460 } |
|
461 } |
|
462 |
|
463 return new static($name, $value, $attributes, [], $reference_time); |
|
464 } |
|
465 |
|
466 /** |
|
467 * Parse all Set-Cookie headers from request headers |
|
468 * |
|
469 * @param \WpOrg\Requests\Response\Headers $headers Headers to parse from |
|
470 * @param \WpOrg\Requests\Iri|null $origin URI for comparing cookie origins |
|
471 * @param int|null $time Reference time for expiration calculation |
|
472 * @return array |
|
473 * |
|
474 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $origin argument is not null or an instance of the Iri class. |
|
475 */ |
|
476 public static function parse_from_headers(Headers $headers, $origin = null, $time = null) { |
|
477 $cookie_headers = $headers->getValues('Set-Cookie'); |
|
478 if (empty($cookie_headers)) { |
|
479 return []; |
|
480 } |
|
481 |
|
482 if ($origin !== null && !($origin instanceof Iri)) { |
|
483 throw InvalidArgument::create(2, '$origin', Iri::class . ' or null', gettype($origin)); |
|
484 } |
|
485 |
|
486 $cookies = []; |
|
487 foreach ($cookie_headers as $header) { |
|
488 $parsed = self::parse($header, '', $time); |
|
489 |
|
490 // Default domain/path attributes |
|
491 if (empty($parsed->attributes['domain']) && !empty($origin)) { |
|
492 $parsed->attributes['domain'] = $origin->host; |
|
493 $parsed->flags['host-only'] = true; |
|
494 } else { |
|
495 $parsed->flags['host-only'] = false; |
|
496 } |
|
497 |
|
498 $path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/'); |
|
499 if (!$path_is_valid && !empty($origin)) { |
|
500 $path = $origin->path; |
|
501 |
|
502 // Default path normalization as per RFC 6265 section 5.1.4 |
|
503 if (substr($path, 0, 1) !== '/') { |
|
504 // If the uri-path is empty or if the first character of |
|
505 // the uri-path is not a %x2F ("/") character, output |
|
506 // %x2F ("/") and skip the remaining steps. |
|
507 $path = '/'; |
|
508 } elseif (substr_count($path, '/') === 1) { |
|
509 // If the uri-path contains no more than one %x2F ("/") |
|
510 // character, output %x2F ("/") and skip the remaining |
|
511 // step. |
|
512 $path = '/'; |
|
513 } else { |
|
514 // Output the characters of the uri-path from the first |
|
515 // character up to, but not including, the right-most |
|
516 // %x2F ("/"). |
|
517 $path = substr($path, 0, strrpos($path, '/')); |
|
518 } |
|
519 |
|
520 $parsed->attributes['path'] = $path; |
|
521 } |
|
522 |
|
523 // Reject invalid cookie domains |
|
524 if (!empty($origin) && !$parsed->domain_matches($origin->host)) { |
|
525 continue; |
|
526 } |
|
527 |
|
528 $cookies[$parsed->name] = $parsed; |
|
529 } |
|
530 |
|
531 return $cookies; |
|
532 } |
|
533 } |