Completed
Pull Request — master (#271)
by
unknown
03:48
created

Cookie::parseFromHeaders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
namespace Rmccue\Requests;
3
4
Use Rmccue\Requests as Requests;
5
Use Rmccue\Requests\IRI as IRI;
6
Use Rmccue\Requests\Response\Headers as Headers;
7
/**
8
 * Cookie storage object
9
 *
10
 * @package Rmccue\Requests
11
 * @subpackage Cookies
12
 */
13
14
/**
15
 * Cookie storage object
16
 *
17
 * @package Rmccue\Requests
18
 * @subpackage Cookies
19
 */
20
class Cookie {
21
	/**
22
	 * Cookie name.
23
	 *
24
	 * @var string
25
	 */
26
	public $name;
27
28
	/**
29
	 * Cookie value.
30
	 *
31
	 * @var string
32
	 */
33
	public $value;
34
35
	/**
36
	 * Cookie attributes
37
	 *
38
	 * Valid keys are (currently) path, domain, expires, max-age, secure and
39
	 * httponly.
40
	 *
41
	 * @var Rmccue\Requests\Utility\CaseInsensitiveDictionary|array Array-like object
42
	 */
43
	public $attributes = array();
44
45
	/**
46
	 * Cookie flags
47
	 *
48
	 * Valid keys are (currently) creation, last-access, persistent and
49
	 * host-only.
50
	 *
51
	 * @var array
52
	 */
53
	public $flags = array();
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
69
	 * @param string $value
70
	 * @param array|\Rmccue\Requests\Utility\CaseInsensitiveDictionary $attributes Associative array of attribute data
71
	 */
72
	public function __construct($name, $value, $attributes = array(), $flags = array(), $reference_time = null) {
73
		$this->name = $name;
74
		$this->value = $value;
75
		$this->attributes = $attributes;
0 ignored issues
show
Documentation Bug introduced by
It seems like $attributes can also be of type object<Rmccue\Requests\U...eInsensitiveDictionary>. However, the property $attributes is declared as type object<Rmccue\Requests\R...sitiveDictionary>|array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
76
		$default_flags = array(
77
			'creation' => time(),
78
			'last-access' => time(),
79
			'persistent' => false,
80
			'host-only' => true,
81
		);
82
		$this->flags = array_merge($default_flags, $flags);
83
84
		$this->reference_time = time();
85
		if ($reference_time !== null) {
86
			$this->reference_time = $reference_time;
87
		}
88
89
		$this->normalize();
90
	}
91
92
	/**
93
	 * Check if a cookie is expired.
94
	 *
95
	 * Checks the age against $this->reference_time to determine if the cookie
96
	 * is expired.
97
	 *
98
	 * @return boolean True if expired, false if time is valid.
99
	 */
100
	public function is_expired() {
101
		// RFC6265, s. 4.1.2.2:
0 ignored issues
show
Unused Code Comprehensibility introduced by
46% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
102
		// If a cookie has both the Max-Age and the Expires attribute, the Max-
103
		// Age attribute has precedence and controls the expiration date of the
104
		// cookie.
105
		if (isset($this->attributes['max-age'])) {
106
			$max_age = $this->attributes['max-age'];
107
			return $max_age < $this->reference_time;
108
		}
109
110
		if (isset($this->attributes['expires'])) {
111
			$expires = $this->attributes['expires'];
112
			return $expires < $this->reference_time;
113
		}
114
115
		return false;
116
	}
117
118
	/**
119
	 * Check if a cookie is valid for a given URI
120
	 *
121
	 * @param \Rmccue\Requests\IRI $uri URI to check
122
	 * @return boolean Whether the cookie is valid for the given URI
123
	 */
124
	public function uri_matches(IRI $uri) {
125
		if (!$this->domain_matches($uri->host)) {
126
			return false;
127
		}
128
129
		if (!$this->path_matches($uri->path)) {
130
			return false;
131
		}
132
133
		return empty($this->attributes['secure']) || $uri->scheme === 'https';
134
	}
135
136
	/**
137
	 * Check if a cookie is valid for a given domain
138
	 *
139
	 * @param string $string Domain to check
140
	 * @return boolean Whether the cookie is valid for the given domain
141
	 */
142
	public function domain_matches($string) {
143
		if (!isset($this->attributes['domain'])) {
144
			// Cookies created manually; cookies created by Requests will set
145
			// the domain to the requested domain
146
			return true;
147
		}
148
149
		$domain_string = $this->attributes['domain'];
150
		if ($domain_string === $string) {
151
			// The domain string and the string are identical.
152
			return true;
153
		}
154
155
		// If the cookie is marked as host-only and we don't have an exact
156
		// match, reject the cookie
157
		if ($this->flags['host-only'] === true) {
158
			return false;
159
		}
160
161
		if (strlen($string) <= strlen($domain_string)) {
162
			// For obvious reasons, the string cannot be a suffix if the domain
163
			// is shorter than the domain string
164
			return false;
165
		}
166
167
		if (substr($string, -1 * strlen($domain_string)) !== $domain_string) {
168
			// The domain string should be a suffix of the string.
169
			return false;
170
		}
171
172
		$prefix = substr($string, 0, strlen($string) - strlen($domain_string));
173
		if (substr($prefix, -1) !== '.') {
174
			// The last character of the string that is not included in the
175
			// domain string should be a %x2E (".") character.
176
			return false;
177
		}
178
179
		// The string should be a host name (i.e., not an IP address).
180
		return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $string);
181
	}
182
183
	/**
184
	 * Check if a cookie is valid for a given path
185
	 *
186
	 * From the path-match check in RFC 6265 section 5.1.4
187
	 *
188
	 * @param string $request_path Path to check
189
	 * @return boolean Whether the cookie is valid for the given path
190
	 */
191
	public function path_matches($request_path) {
192
		if (empty($request_path)) {
193
			// Normalize empty path to root
194
			$request_path = '/';
195
		}
196
197
		if (!isset($this->attributes['path'])) {
198
			// Cookies created manually; cookies created by Requests will set
199
			// the path to the requested path
200
			return true;
201
		}
202
203
		$cookie_path = $this->attributes['path'];
204
205
		if ($cookie_path === $request_path) {
206
			// The cookie-path and the request-path are identical.
207
			return true;
208
		}
209
210
		if (strlen($request_path) > strlen($cookie_path) && substr($request_path, 0, strlen($cookie_path)) === $cookie_path) {
211
			if (substr($cookie_path, -1) === '/') {
212
				// The cookie-path is a prefix of the request-path, and the last
213
				// character of the cookie-path is %x2F ("/").
214
				return true;
215
			}
216
217
			if (substr($request_path, strlen($cookie_path), 1) === '/') {
218
				// The cookie-path is a prefix of the request-path, and the
219
				// first character of the request-path that is not included in
220
				// the cookie-path is a %x2F ("/") character.
221
				return true;
222
			}
223
		}
224
225
		return false;
226
	}
227
228
	/**
229
	 * Normalize cookie and attributes
230
	 *
231
	 * @return boolean Whether the cookie was successfully normalized
232
	 */
233
	public function normalize() {
234
		foreach ($this->attributes as $key => $value) {
235
			$orig_value = $value;
236
			$value = $this->normalize_attribute($key, $value);
237
			if ($value === null) {
238
				unset($this->attributes[$key]);
239
				continue;
240
			}
241
242
			if ($value !== $orig_value) {
243
				$this->attributes[$key] = $value;
244
			}
245
		}
246
247
		return true;
248
	}
249
250
	/**
251
	 * Parse an individual cookie attribute
252
	 *
253
	 * Handles parsing individual attributes from the cookie values.
254
	 *
255
	 * @param string $name Attribute name
256
	 * @param string|boolean $value Attribute value (string value, or true if empty/flag)
257
	 * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped)
258
	 */
259
	protected function normalize_attribute($name, $value) {
260
		switch (strtolower($name)) {
261
			case 'expires':
262
				// Expiration parsing, as per RFC 6265 section 5.2.1
263
				if (is_int($value)) {
264
					return $value;
265
				}
266
267
				$expiry_time = strtotime($value);
268
				if ($expiry_time === false) {
269
					return null;
270
				}
271
272
				return $expiry_time;
273
274
			case 'max-age':
275
				// Expiration parsing, as per RFC 6265 section 5.2.2
276
				if (is_int($value)) {
277
					return $value;
278
				}
279
280
				// Check that we have a valid age
281
				if (!preg_match('/^-?\d+$/', $value)) {
282
					return null;
283
				}
284
285
				$delta_seconds = (int) $value;
286
				if ($delta_seconds <= 0) {
287
					$expiry_time = 0;
288
				}
289
				else {
290
					$expiry_time = $this->reference_time + $delta_seconds;
291
				}
292
293
				return $expiry_time;
294
295
			case 'domain':
296
				// Domain normalization, as per RFC 6265 section 5.2.3
297
				if ($value[0] === '.') {
298
					$value = substr($value, 1);
299
				}
300
301
				return $value;
302
303
			default:
304
				return $value;
305
		}
306
	}
307
308
	/**
309
	 * Format a cookie for a Cookie header
310
	 *
311
	 * This is used when sending cookies to a server.
312
	 *
313
	 * @return string Cookie formatted for Cookie header
314
	 */
315
	public function format_for_header() {
316
		return sprintf('%s=%s', $this->name, $this->value);
317
	}
318
319
	/**
320
	 * Format a cookie for a Cookie header
321
	 *
322
	 * @codeCoverageIgnore
323
	 * @deprecated Use {@see Rmccue\Requests\Cookie::format_for_header}
324
	 * @return string
325
	 */
326
	public function formatForHeader() {
327
		return $this->format_for_header();
328
	}
329
330
	/**
331
	 * Format a cookie for a Set-Cookie header
332
	 *
333
	 * This is used when sending cookies to clients. This isn't really
334
	 * applicable to client-side usage, but might be handy for debugging.
335
	 *
336
	 * @return string Cookie formatted for Set-Cookie header
337
	 */
338
	public function format_for_set_cookie() {
339
		$header_value = $this->format_for_header();
340
		if (!empty($this->attributes)) {
341
			$parts = array();
342
			foreach ($this->attributes as $key => $value) {
343
				// Ignore non-associative attributes
344
				if (is_numeric($key)) {
345
					$parts[] = $value;
346
				}
347
				else {
348
					$parts[] = sprintf('%s=%s', $key, $value);
349
				}
350
			}
351
352
			$header_value .= '; ' . implode('; ', $parts);
353
		}
354
		return $header_value;
355
	}
356
357
	/**
358
	 * Format a cookie for a Set-Cookie header
359
	 *
360
	 * @codeCoverageIgnore
361
	 * @deprecated Use {@see Rmccue\Requests\Cookie::format_for_set_cookie}
362
	 * @return string
363
	 */
364
	public function formatForSetCookie() {
365
		return $this->format_for_set_cookie();
366
	}
367
368
	/**
369
	 * Get the cookie value
370
	 *
371
	 * Attributes and other data can be accessed via methods.
372
	 */
373
	public function __toString() {
374
		return $this->value;
375
	}
376
377
	/**
378
	 * Parse a cookie string into a cookie object
379
	 *
380
	 * Based on Mozilla's parsing code in Firefox and related projects, which
381
	 * is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265
382
	 * specifies some of this handling, but not in a thorough manner.
383
	 *
384
	 * @param string Cookie header value (from a Set-Cookie header)
385
	 * @return Rmccue\Requests\Cookie Parsed cookie object
386
	 */
387
	public static function parse($string, $name = '', $reference_time = null) {
388
		$parts = explode(';', $string);
389
		$kvparts = array_shift($parts);
390
391
		if (!empty($name)) {
392
			$value = $string;
393
		}
394
		elseif (strpos($kvparts, '=') === false) {
395
			// Some sites might only have a value without the equals separator.
396
			// Deviate from RFC 6265 and pretend it was actually a blank name
397
			// (`=foo`)
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
398
			//
399
			// https://bugzilla.mozilla.org/show_bug.cgi?id=169091
400
			$name = '';
401
			$value = $kvparts;
402
		}
403
		else {
404
			list($name, $value) = explode('=', $kvparts, 2);
405
		}
406
		$name = trim($name);
407
		$value = trim($value);
408
409
		// Attribute key are handled case-insensitively
410
		$attributes = new \Rmccue\Requests\Utility\CaseInsensitiveDictionary();
411
412
		if (!empty($parts)) {
413
			foreach ($parts as $part) {
414
				if (strpos($part, '=') === false) {
415
					$part_key = $part;
416
					$part_value = true;
417
				}
418
				else {
419
					list($part_key, $part_value) = explode('=', $part, 2);
420
					$part_value = trim($part_value);
421
				}
422
423
				$part_key = trim($part_key);
424
				$attributes[$part_key] = $part_value;
425
			}
426
		}
427
428
		return new \Rmccue\Requests\Cookie($name, $value, $attributes, array(), $reference_time);
429
	}
430
431
	/**
432
	 * Parse all Set-Cookie headers from request headers
433
	 *
434
	 * @param Rmccue\Requests\Response\Headers $headers Headers to parse from
0 ignored issues
show
Documentation introduced by
Should the type for parameter $headers not be Headers?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
435
	 * @param Rmccue\Requests\IRI|null $origin URI for comparing cookie origins
0 ignored issues
show
Documentation introduced by
Should the type for parameter $origin not be null|IRI?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
436
	 * @param int|null $time Reference time for expiration calculation
437
	 * @return array
438
	 */
439
	public static function parse_from_headers(Headers $headers, IRI $origin = null, $time = null) {
440
		$cookie_headers = $headers->getValues('Set-Cookie');
441
		if (empty($cookie_headers)) {
442
			return array();
443
		}
444
445
		$cookies = array();
446
		foreach ($cookie_headers as $header) {
447
			$parsed = self::parse($header, '', $time);
448
449
			// Default domain/path attributes
450
			if (empty($parsed->attributes['domain']) && !empty($origin)) {
451
				$parsed->attributes['domain'] = $origin->host;
452
				$parsed->flags['host-only'] = true;
453
			}
454
			else {
455
				$parsed->flags['host-only'] = false;
456
			}
457
458
			$path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/');
459
			if (!$path_is_valid && !empty($origin)) {
460
				$path = $origin->path;
461
462
				// Default path normalization as per RFC 6265 section 5.1.4
463
				if (substr($path, 0, 1) !== '/') {
464
					// If the uri-path is empty or if the first character of
465
					// the uri-path is not a %x2F ("/") character, output
466
					// %x2F ("/") and skip the remaining steps.
467
					$path = '/';
468
				}
469
				elseif (substr_count($path, '/') === 1) {
470
					// If the uri-path contains no more than one %x2F ("/")
471
					// character, output %x2F ("/") and skip the remaining
472
					// step.
473
					$path = '/';
474
				}
475
				else {
476
					// Output the characters of the uri-path from the first
477
					// character up to, but not including, the right-most
478
					// %x2F ("/").
0 ignored issues
show
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
479
					$path = substr($path, 0, strrpos($path, '/'));
480
				}
481
				$parsed->attributes['path'] = $path;
482
			}
483
484
			// Reject invalid cookie domains
485
			if (!empty($origin) && !$parsed->domain_matches($origin->host)) {
486
				continue;
487
			}
488
489
			$cookies[$parsed->name] = $parsed;
490
		}
491
492
		return $cookies;
493
	}
494
495
	/**
496
	 * Parse all Set-Cookie headers from request headers
497
	 *
498
	 * @codeCoverageIgnore
499
	 * @deprecated Use {@see Rmccue\Requests\Cookie::parse_from_headers}
500
	 * @return string
501
	 */
502
	public static function parseFromHeaders(Rmccue\Requests\Response\Headers $headers) {
503
		return self::parse_from_headers($headers);
504
	}
505
}
506