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