1<?php 2/** 3 * Stores cookies and passes them between HTTP requests 4 * 5 * PHP version 5 6 * 7 * LICENSE 8 * 9 * This source file is subject to BSD 3-Clause License that is bundled 10 * with this package in the file LICENSE and available at the URL 11 * https://raw.github.com/pear/HTTP_Request2/trunk/docs/LICENSE 12 * 13 * @category HTTP 14 * @package HTTP_Request2 15 * @author Alexey Borzov <avb@php.net> 16 * @copyright 2008-2016 Alexey Borzov <avb@php.net> 17 * @license http://opensource.org/licenses/BSD-3-Clause BSD 3-Clause License 18 * @link http://pear.php.net/package/HTTP_Request2 19 */ 20 21/** Class representing a HTTP request message */ 22require_once 'HTTP/Request2.php'; 23 24/** 25 * Stores cookies and passes them between HTTP requests 26 * 27 * @category HTTP 28 * @package HTTP_Request2 29 * @author Alexey Borzov <avb@php.net> 30 * @license http://opensource.org/licenses/BSD-3-Clause BSD 3-Clause License 31 * @version Release: @package_version@ 32 * @link http://pear.php.net/package/HTTP_Request2 33 */ 34class HTTP_Request2_CookieJar implements Serializable 35{ 36 /** 37 * Array of stored cookies 38 * 39 * The array is indexed by domain, path and cookie name 40 * .example.com 41 * / 42 * some_cookie => cookie data 43 * /subdir 44 * other_cookie => cookie data 45 * .example.org 46 * ... 47 * 48 * @var array 49 */ 50 protected $cookies = array(); 51 52 /** 53 * Whether session cookies should be serialized when serializing the jar 54 * @var bool 55 */ 56 protected $serializeSession = false; 57 58 /** 59 * Whether Public Suffix List should be used for domain matching 60 * @var bool 61 */ 62 protected $useList = true; 63 64 /** 65 * Whether an attempt to store an invalid cookie should be ignored, rather than cause an Exception 66 * @var bool 67 */ 68 protected $ignoreInvalid = false; 69 70 /** 71 * Array with Public Suffix List data 72 * @var array 73 * @link http://publicsuffix.org/ 74 */ 75 protected static $psl = array(); 76 77 /** 78 * Class constructor, sets various options 79 * 80 * @param bool $serializeSessionCookies Controls serializing session cookies, 81 * see {@link serializeSessionCookies()} 82 * @param bool $usePublicSuffixList Controls using Public Suffix List, 83 * see {@link usePublicSuffixList()} 84 * @param bool $ignoreInvalidCookies Whether invalid cookies should be ignored, 85 * see {@link ignoreInvalidCookies()} 86 */ 87 public function __construct( 88 $serializeSessionCookies = false, $usePublicSuffixList = true, 89 $ignoreInvalidCookies = false 90 ) { 91 $this->serializeSessionCookies($serializeSessionCookies); 92 $this->usePublicSuffixList($usePublicSuffixList); 93 $this->ignoreInvalidCookies($ignoreInvalidCookies); 94 } 95 96 /** 97 * Returns current time formatted in ISO-8601 at UTC timezone 98 * 99 * @return string 100 */ 101 protected function now() 102 { 103 $dt = new DateTime(); 104 $dt->setTimezone(new DateTimeZone('UTC')); 105 return $dt->format(DateTime::ISO8601); 106 } 107 108 /** 109 * Checks cookie array for correctness, possibly updating its 'domain', 'path' and 'expires' fields 110 * 111 * The checks are as follows: 112 * - cookie array should contain 'name' and 'value' fields; 113 * - name and value should not contain disallowed symbols; 114 * - 'expires' should be either empty parseable by DateTime; 115 * - 'domain' and 'path' should be either not empty or an URL where 116 * cookie was set should be provided. 117 * - if $setter is provided, then document at that URL should be allowed 118 * to set a cookie for that 'domain'. If $setter is not provided, 119 * then no domain checks will be made. 120 * 121 * 'expires' field will be converted to ISO8601 format from COOKIE format, 122 * 'domain' and 'path' will be set from setter URL if empty. 123 * 124 * @param array $cookie cookie data, as returned by 125 * {@link HTTP_Request2_Response::getCookies()} 126 * @param Net_URL2 $setter URL of the document that sent Set-Cookie header 127 * 128 * @return array Updated cookie array 129 * @throws HTTP_Request2_LogicException 130 * @throws HTTP_Request2_MessageException 131 */ 132 protected function checkAndUpdateFields(array $cookie, Net_URL2 $setter = null) 133 { 134 if ($missing = array_diff(array('name', 'value'), array_keys($cookie))) { 135 throw new HTTP_Request2_LogicException( 136 "Cookie array should contain 'name' and 'value' fields", 137 HTTP_Request2_Exception::MISSING_VALUE 138 ); 139 } 140 if (preg_match(HTTP_Request2::REGEXP_INVALID_COOKIE, $cookie['name'])) { 141 throw new HTTP_Request2_LogicException( 142 "Invalid cookie name: '{$cookie['name']}'", 143 HTTP_Request2_Exception::INVALID_ARGUMENT 144 ); 145 } 146 if (preg_match(HTTP_Request2::REGEXP_INVALID_COOKIE, $cookie['value'])) { 147 throw new HTTP_Request2_LogicException( 148 "Invalid cookie value: '{$cookie['value']}'", 149 HTTP_Request2_Exception::INVALID_ARGUMENT 150 ); 151 } 152 $cookie += array('domain' => '', 'path' => '', 'expires' => null, 'secure' => false); 153 154 // Need ISO-8601 date @ UTC timezone 155 if (!empty($cookie['expires']) 156 && !preg_match('/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\+0000$/', $cookie['expires']) 157 ) { 158 try { 159 $dt = new DateTime($cookie['expires']); 160 $dt->setTimezone(new DateTimeZone('UTC')); 161 $cookie['expires'] = $dt->format(DateTime::ISO8601); 162 } catch (Exception $e) { 163 throw new HTTP_Request2_LogicException($e->getMessage()); 164 } 165 } 166 167 if (empty($cookie['domain']) || empty($cookie['path'])) { 168 if (!$setter) { 169 throw new HTTP_Request2_LogicException( 170 'Cookie misses domain and/or path component, cookie setter URL needed', 171 HTTP_Request2_Exception::MISSING_VALUE 172 ); 173 } 174 if (empty($cookie['domain'])) { 175 if ($host = $setter->getHost()) { 176 $cookie['domain'] = $host; 177 } else { 178 throw new HTTP_Request2_LogicException( 179 'Setter URL does not contain host part, can\'t set cookie domain', 180 HTTP_Request2_Exception::MISSING_VALUE 181 ); 182 } 183 } 184 if (empty($cookie['path'])) { 185 $path = $setter->getPath(); 186 $cookie['path'] = empty($path)? '/': substr($path, 0, strrpos($path, '/') + 1); 187 } 188 } 189 190 if ($setter && !$this->domainMatch($setter->getHost(), $cookie['domain'])) { 191 throw new HTTP_Request2_MessageException( 192 "Domain " . $setter->getHost() . " cannot set cookies for " 193 . $cookie['domain'] 194 ); 195 } 196 197 return $cookie; 198 } 199 200 /** 201 * Stores a cookie in the jar 202 * 203 * @param array $cookie cookie data, as returned by 204 * {@link HTTP_Request2_Response::getCookies()} 205 * @param Net_URL2 $setter URL of the document that sent Set-Cookie header 206 * 207 * @return bool whether the cookie was successfully stored 208 * @throws HTTP_Request2_Exception 209 */ 210 public function store(array $cookie, Net_URL2 $setter = null) 211 { 212 try { 213 $cookie = $this->checkAndUpdateFields($cookie, $setter); 214 } catch (HTTP_Request2_Exception $e) { 215 if ($this->ignoreInvalid) { 216 return false; 217 } else { 218 throw $e; 219 } 220 } 221 222 if (strlen($cookie['value']) 223 && (is_null($cookie['expires']) || $cookie['expires'] > $this->now()) 224 ) { 225 if (!isset($this->cookies[$cookie['domain']])) { 226 $this->cookies[$cookie['domain']] = array(); 227 } 228 if (!isset($this->cookies[$cookie['domain']][$cookie['path']])) { 229 $this->cookies[$cookie['domain']][$cookie['path']] = array(); 230 } 231 $this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']] = $cookie; 232 233 } elseif (isset($this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']])) { 234 unset($this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']]); 235 } 236 237 return true; 238 } 239 240 /** 241 * Adds cookies set in HTTP response to the jar 242 * 243 * @param HTTP_Request2_Response $response HTTP response message 244 * @param Net_URL2 $setter original request URL, needed for 245 * setting default domain/path. If not given, 246 * effective URL from response will be used. 247 * 248 * @return bool whether all cookies were successfully stored 249 * @throws HTTP_Request2_LogicException 250 */ 251 public function addCookiesFromResponse(HTTP_Request2_Response $response, Net_URL2 $setter = null) 252 { 253 if (null === $setter) { 254 if (!($effectiveUrl = $response->getEffectiveUrl())) { 255 throw new HTTP_Request2_LogicException( 256 'Response URL required for adding cookies from response', 257 HTTP_Request2_Exception::MISSING_VALUE 258 ); 259 } 260 $setter = new Net_URL2($effectiveUrl); 261 } 262 263 $success = true; 264 foreach ($response->getCookies() as $cookie) { 265 $success = $this->store($cookie, $setter) && $success; 266 } 267 return $success; 268 } 269 270 /** 271 * Returns all cookies matching a given request URL 272 * 273 * The following checks are made: 274 * - cookie domain should match request host 275 * - cookie path should be a prefix for request path 276 * - 'secure' cookies will only be sent for HTTPS requests 277 * 278 * @param Net_URL2 $url Request url 279 * @param bool $asString Whether to return cookies as string for "Cookie: " header 280 * 281 * @return array|string Matching cookies 282 */ 283 public function getMatching(Net_URL2 $url, $asString = false) 284 { 285 $host = $url->getHost(); 286 $path = $url->getPath(); 287 $secure = 0 == strcasecmp($url->getScheme(), 'https'); 288 289 $matched = $ret = array(); 290 foreach (array_keys($this->cookies) as $domain) { 291 if ($this->domainMatch($host, $domain)) { 292 foreach (array_keys($this->cookies[$domain]) as $cPath) { 293 if (0 === strpos($path, $cPath)) { 294 foreach ($this->cookies[$domain][$cPath] as $name => $cookie) { 295 if (!$cookie['secure'] || $secure) { 296 $matched[$name][strlen($cookie['path'])] = $cookie; 297 } 298 } 299 } 300 } 301 } 302 } 303 foreach ($matched as $cookies) { 304 krsort($cookies); 305 $ret = array_merge($ret, $cookies); 306 } 307 if (!$asString) { 308 return $ret; 309 } else { 310 $str = ''; 311 foreach ($ret as $c) { 312 $str .= (empty($str)? '': '; ') . $c['name'] . '=' . $c['value']; 313 } 314 return $str; 315 } 316 } 317 318 /** 319 * Returns all cookies stored in a jar 320 * 321 * @return array 322 */ 323 public function getAll() 324 { 325 $cookies = array(); 326 foreach (array_keys($this->cookies) as $domain) { 327 foreach (array_keys($this->cookies[$domain]) as $path) { 328 foreach ($this->cookies[$domain][$path] as $name => $cookie) { 329 $cookies[] = $cookie; 330 } 331 } 332 } 333 return $cookies; 334 } 335 336 /** 337 * Sets whether session cookies should be serialized when serializing the jar 338 * 339 * @param boolean $serialize serialize? 340 */ 341 public function serializeSessionCookies($serialize) 342 { 343 $this->serializeSession = (bool)$serialize; 344 } 345 346 /** 347 * Sets whether invalid cookies should be silently ignored or cause an Exception 348 * 349 * @param boolean $ignore ignore? 350 * @link http://pear.php.net/bugs/bug.php?id=19937 351 * @link http://pear.php.net/bugs/bug.php?id=20401 352 */ 353 public function ignoreInvalidCookies($ignore) 354 { 355 $this->ignoreInvalid = (bool)$ignore; 356 } 357 358 /** 359 * Sets whether Public Suffix List should be used for restricting cookie-setting 360 * 361 * Without PSL {@link domainMatch()} will only prevent setting cookies for 362 * top-level domains like '.com' or '.org'. However, it will not prevent 363 * setting a cookie for '.co.uk' even though only third-level registrations 364 * are possible in .uk domain. 365 * 366 * With the List it is possible to find the highest level at which a domain 367 * may be registered for a particular top-level domain and consequently 368 * prevent cookies set for '.co.uk' or '.msk.ru'. The same list is used by 369 * Firefox, Chrome and Opera browsers to restrict cookie setting. 370 * 371 * Note that PSL is licensed differently to HTTP_Request2 package (refer to 372 * the license information in public-suffix-list.php), so you can disable 373 * its use if this is an issue for you. 374 * 375 * @param boolean $useList use the list? 376 * 377 * @link http://publicsuffix.org/learn/ 378 */ 379 public function usePublicSuffixList($useList) 380 { 381 $this->useList = (bool)$useList; 382 } 383 384 /** 385 * Returns string representation of object 386 * 387 * @return string 388 * 389 * @see Serializable::serialize() 390 */ 391 public function serialize() 392 { 393 $cookies = $this->getAll(); 394 if (!$this->serializeSession) { 395 for ($i = count($cookies) - 1; $i >= 0; $i--) { 396 if (empty($cookies[$i]['expires'])) { 397 unset($cookies[$i]); 398 } 399 } 400 } 401 return serialize(array( 402 'cookies' => $cookies, 403 'serializeSession' => $this->serializeSession, 404 'useList' => $this->useList, 405 'ignoreInvalid' => $this->ignoreInvalid 406 )); 407 } 408 409 /** 410 * Constructs the object from serialized string 411 * 412 * @param string $serialized string representation 413 * 414 * @see Serializable::unserialize() 415 */ 416 public function unserialize($serialized) 417 { 418 $data = unserialize($serialized); 419 $now = $this->now(); 420 $this->serializeSessionCookies($data['serializeSession']); 421 $this->usePublicSuffixList($data['useList']); 422 if (array_key_exists('ignoreInvalid', $data)) { 423 $this->ignoreInvalidCookies($data['ignoreInvalid']); 424 } 425 foreach ($data['cookies'] as $cookie) { 426 if (!empty($cookie['expires']) && $cookie['expires'] <= $now) { 427 continue; 428 } 429 if (!isset($this->cookies[$cookie['domain']])) { 430 $this->cookies[$cookie['domain']] = array(); 431 } 432 if (!isset($this->cookies[$cookie['domain']][$cookie['path']])) { 433 $this->cookies[$cookie['domain']][$cookie['path']] = array(); 434 } 435 $this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']] = $cookie; 436 } 437 } 438 439 /** 440 * Checks whether a cookie domain matches a request host. 441 * 442 * The method is used by {@link store()} to check for whether a document 443 * at given URL can set a cookie with a given domain attribute and by 444 * {@link getMatching()} to find cookies matching the request URL. 445 * 446 * @param string $requestHost request host 447 * @param string $cookieDomain cookie domain 448 * 449 * @return bool match success 450 */ 451 public function domainMatch($requestHost, $cookieDomain) 452 { 453 if ($requestHost == $cookieDomain) { 454 return true; 455 } 456 // IP address, we require exact match 457 if (preg_match('/^(?:\d{1,3}\.){3}\d{1,3}$/', $requestHost)) { 458 return false; 459 } 460 if ('.' != $cookieDomain[0]) { 461 $cookieDomain = '.' . $cookieDomain; 462 } 463 // prevents setting cookies for '.com' and similar domains 464 if (!$this->useList && substr_count($cookieDomain, '.') < 2 465 || $this->useList && !self::getRegisteredDomain($cookieDomain) 466 ) { 467 return false; 468 } 469 return substr('.' . $requestHost, -strlen($cookieDomain)) == $cookieDomain; 470 } 471 472 /** 473 * Removes subdomains to get the registered domain (the first after top-level) 474 * 475 * The method will check Public Suffix List to find out where top-level 476 * domain ends and registered domain starts. It will remove domain parts 477 * to the left of registered one. 478 * 479 * @param string $domain domain name 480 * 481 * @return string|bool registered domain, will return false if $domain is 482 * either invalid or a TLD itself 483 */ 484 public static function getRegisteredDomain($domain) 485 { 486 $domainParts = explode('.', ltrim($domain, '.')); 487 488 // load the list if needed 489 if (empty(self::$psl)) { 490 $path = '@data_dir@' . DIRECTORY_SEPARATOR . 'HTTP_Request2'; 491 if (0 === strpos($path, '@' . 'data_dir@')) { 492 $path = realpath( 493 dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' 494 . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'data' 495 ); 496 } 497 self::$psl = include_once $path . DIRECTORY_SEPARATOR . 'public-suffix-list.php'; 498 } 499 500 if (!($result = self::checkDomainsList($domainParts, self::$psl))) { 501 // known TLD, invalid domain name 502 return false; 503 } 504 505 // unknown TLD 506 if (!strpos($result, '.')) { 507 // fallback to checking that domain "has at least two dots" 508 if (2 > ($count = count($domainParts))) { 509 return false; 510 } 511 return $domainParts[$count - 2] . '.' . $domainParts[$count - 1]; 512 } 513 return $result; 514 } 515 516 /** 517 * Recursive helper method for {@link getRegisteredDomain()} 518 * 519 * @param array $domainParts remaining domain parts 520 * @param mixed $listNode node in {@link HTTP_Request2_CookieJar::$psl} to check 521 * 522 * @return string|null concatenated domain parts, null in case of error 523 */ 524 protected static function checkDomainsList(array $domainParts, $listNode) 525 { 526 $sub = array_pop($domainParts); 527 $result = null; 528 529 if (!is_array($listNode) || is_null($sub) 530 || array_key_exists('!' . $sub, $listNode) 531 ) { 532 return $sub; 533 534 } elseif (array_key_exists($sub, $listNode)) { 535 $result = self::checkDomainsList($domainParts, $listNode[$sub]); 536 537 } elseif (array_key_exists('*', $listNode)) { 538 $result = self::checkDomainsList($domainParts, $listNode['*']); 539 540 } else { 541 return $sub; 542 } 543 544 return (strlen($result) > 0) ? ($result . '.' . $sub) : null; 545 } 546} 547?>