3 * Stores cookies and passes them between HTTP requests
\r
9 * Copyright (c) 2008-2011, Alexey Borzov <avb@php.net>
\r
10 * All rights reserved.
\r
12 * Redistribution and use in source and binary forms, with or without
\r
13 * modification, are permitted provided that the following conditions
\r
16 * * Redistributions of source code must retain the above copyright
\r
17 * notice, this list of conditions and the following disclaimer.
\r
18 * * Redistributions in binary form must reproduce the above copyright
\r
19 * notice, this list of conditions and the following disclaimer in the
\r
20 * documentation and/or other materials provided with the distribution.
\r
21 * * The names of the authors may not be used to endorse or promote products
\r
22 * derived from this software without specific prior written permission.
\r
24 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
\r
25 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
\r
26 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
\r
27 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
\r
28 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
\r
29 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
\r
30 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
\r
31 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
\r
32 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
\r
33 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
\r
34 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\r
37 * @package HTTP_Request2
\r
38 * @author Alexey Borzov <avb@php.net>
\r
39 * @license http://opensource.org/licenses/bsd-license.php New BSD License
\r
40 * @version SVN: $Id: CookieJar.php 308629 2011-02-24 17:34:24Z avb $
\r
41 * @link http://pear.php.net/package/HTTP_Request2
\r
44 /** Class representing a HTTP request message */
\r
45 require_once 'HTTP/Request2.php';
\r
48 * Stores cookies and passes them between HTTP requests
\r
51 * @package HTTP_Request2
\r
52 * @author Alexey Borzov <avb@php.net>
\r
53 * @version Release: @package_version@
\r
55 class HTTP_Request2_CookieJar implements Serializable
\r
58 * Array of stored cookies
\r
60 * The array is indexed by domain, path and cookie name
\r
63 * some_cookie => cookie data
\r
65 * other_cookie => cookie data
\r
71 protected $cookies = array();
\r
74 * Whether session cookies should be serialized when serializing the jar
\r
77 protected $serializeSession = false;
\r
80 * Whether Public Suffix List should be used for domain matching
\r
83 protected $useList = true;
\r
86 * Array with Public Suffix List data
\r
88 * @link http://publicsuffix.org/
\r
90 protected static $psl = array();
\r
93 * Class constructor, sets various options
\r
95 * @param bool Controls serializing session cookies, see {@link serializeSessionCookies()}
\r
96 * @param bool Controls using Public Suffix List, see {@link usePublicSuffixList()}
\r
98 public function __construct($serializeSessionCookies = false, $usePublicSuffixList = true)
\r
100 $this->serializeSessionCookies($serializeSessionCookies);
\r
101 $this->usePublicSuffixList($usePublicSuffixList);
\r
105 * Returns current time formatted in ISO-8601 at UTC timezone
\r
109 protected function now()
\r
111 $dt = new DateTime();
\r
112 $dt->setTimezone(new DateTimeZone('UTC'));
\r
113 return $dt->format(DateTime::ISO8601);
\r
117 * Checks cookie array for correctness, possibly updating its 'domain', 'path' and 'expires' fields
\r
119 * The checks are as follows:
\r
120 * - cookie array should contain 'name' and 'value' fields;
\r
121 * - name and value should not contain disallowed symbols;
\r
122 * - 'expires' should be either empty parseable by DateTime;
\r
123 * - 'domain' and 'path' should be either not empty or an URL where
\r
124 * cookie was set should be provided.
\r
125 * - if $setter is provided, then document at that URL should be allowed
\r
126 * to set a cookie for that 'domain'. If $setter is not provided,
\r
127 * then no domain checks will be made.
\r
129 * 'expires' field will be converted to ISO8601 format from COOKIE format,
\r
130 * 'domain' and 'path' will be set from setter URL if empty.
\r
132 * @param array cookie data, as returned by {@link HTTP_Request2_Response::getCookies()}
\r
133 * @param Net_URL2 URL of the document that sent Set-Cookie header
\r
134 * @return array Updated cookie array
\r
135 * @throws HTTP_Request2_LogicException
\r
136 * @throws HTTP_Request2_MessageException
\r
138 protected function checkAndUpdateFields(array $cookie, Net_URL2 $setter = null)
\r
140 if ($missing = array_diff(array('name', 'value'), array_keys($cookie))) {
\r
141 throw new HTTP_Request2_LogicException(
\r
142 "Cookie array should contain 'name' and 'value' fields",
\r
143 HTTP_Request2_Exception::MISSING_VALUE
\r
146 if (preg_match(HTTP_Request2::REGEXP_INVALID_COOKIE, $cookie['name'])) {
\r
147 throw new HTTP_Request2_LogicException(
\r
148 "Invalid cookie name: '{$cookie['name']}'",
\r
149 HTTP_Request2_Exception::INVALID_ARGUMENT
\r
152 if (preg_match(HTTP_Request2::REGEXP_INVALID_COOKIE, $cookie['value'])) {
\r
153 throw new HTTP_Request2_LogicException(
\r
154 "Invalid cookie value: '{$cookie['value']}'",
\r
155 HTTP_Request2_Exception::INVALID_ARGUMENT
\r
158 $cookie += array('domain' => '', 'path' => '', 'expires' => null, 'secure' => false);
\r
160 // Need ISO-8601 date @ UTC timezone
\r
161 if (!empty($cookie['expires'])
\r
162 && !preg_match('/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\+0000$/', $cookie['expires'])
\r
165 $dt = new DateTime($cookie['expires']);
\r
166 $dt->setTimezone(new DateTimeZone('UTC'));
\r
167 $cookie['expires'] = $dt->format(DateTime::ISO8601);
\r
168 } catch (Exception $e) {
\r
169 throw new HTTP_Request2_LogicException($e->getMessage());
\r
173 if (empty($cookie['domain']) || empty($cookie['path'])) {
\r
175 throw new HTTP_Request2_LogicException(
\r
176 'Cookie misses domain and/or path component, cookie setter URL needed',
\r
177 HTTP_Request2_Exception::MISSING_VALUE
\r
180 if (empty($cookie['domain'])) {
\r
181 if ($host = $setter->getHost()) {
\r
182 $cookie['domain'] = $host;
\r
184 throw new HTTP_Request2_LogicException(
\r
185 'Setter URL does not contain host part, can\'t set cookie domain',
\r
186 HTTP_Request2_Exception::MISSING_VALUE
\r
190 if (empty($cookie['path'])) {
\r
191 $path = $setter->getPath();
\r
192 $cookie['path'] = empty($path)? '/': substr($path, 0, strrpos($path, '/') + 1);
\r
196 if ($setter && !$this->domainMatch($setter->getHost(), $cookie['domain'])) {
\r
197 throw new HTTP_Request2_MessageException(
\r
198 "Domain " . $setter->getHost() . " cannot set cookies for "
\r
199 . $cookie['domain']
\r
207 * Stores a cookie in the jar
\r
209 * @param array cookie data, as returned by {@link HTTP_Request2_Response::getCookies()}
\r
210 * @param Net_URL2 URL of the document that sent Set-Cookie header
\r
211 * @throws HTTP_Request2_Exception
\r
213 public function store(array $cookie, Net_URL2 $setter = null)
\r
215 $cookie = $this->checkAndUpdateFields($cookie, $setter);
\r
217 if (strlen($cookie['value'])
\r
218 && (is_null($cookie['expires']) || $cookie['expires'] > $this->now())
\r
220 if (!isset($this->cookies[$cookie['domain']])) {
\r
221 $this->cookies[$cookie['domain']] = array();
\r
223 if (!isset($this->cookies[$cookie['domain']][$cookie['path']])) {
\r
224 $this->cookies[$cookie['domain']][$cookie['path']] = array();
\r
226 $this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']] = $cookie;
\r
228 } elseif (isset($this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']])) {
\r
229 unset($this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']]);
\r
234 * Adds cookies set in HTTP response to the jar
\r
236 * @param HTTP_Request2_Response response
\r
237 * @param Net_URL2 original request URL, needed for setting
\r
238 * default domain/path
\r
240 public function addCookiesFromResponse(HTTP_Request2_Response $response, Net_URL2 $setter)
\r
242 foreach ($response->getCookies() as $cookie) {
\r
243 $this->store($cookie, $setter);
\r
248 * Returns all cookies matching a given request URL
\r
250 * The following checks are made:
\r
251 * - cookie domain should match request host
\r
252 * - cookie path should be a prefix for request path
\r
253 * - 'secure' cookies will only be sent for HTTPS requests
\r
256 * @param bool Whether to return cookies as string for "Cookie: " header
\r
259 public function getMatching(Net_URL2 $url, $asString = false)
\r
261 $host = $url->getHost();
\r
262 $path = $url->getPath();
\r
263 $secure = 0 == strcasecmp($url->getScheme(), 'https');
\r
265 $matched = $ret = array();
\r
266 foreach (array_keys($this->cookies) as $domain) {
\r
267 if ($this->domainMatch($host, $domain)) {
\r
268 foreach (array_keys($this->cookies[$domain]) as $cPath) {
\r
269 if (0 === strpos($path, $cPath)) {
\r
270 foreach ($this->cookies[$domain][$cPath] as $name => $cookie) {
\r
271 if (!$cookie['secure'] || $secure) {
\r
272 $matched[$name][strlen($cookie['path'])] = $cookie;
\r
279 foreach ($matched as $cookies) {
\r
281 $ret = array_merge($ret, $cookies);
\r
287 foreach ($ret as $c) {
\r
288 $str .= (empty($str)? '': '; ') . $c['name'] . '=' . $c['value'];
\r
295 * Returns all cookies stored in a jar
\r
299 public function getAll()
\r
301 $cookies = array();
\r
302 foreach (array_keys($this->cookies) as $domain) {
\r
303 foreach (array_keys($this->cookies[$domain]) as $path) {
\r
304 foreach ($this->cookies[$domain][$path] as $name => $cookie) {
\r
305 $cookies[] = $cookie;
\r
313 * Sets whether session cookies should be serialized when serializing the jar
\r
317 public function serializeSessionCookies($serialize)
\r
319 $this->serializeSession = (bool)$serialize;
\r
323 * Sets whether Public Suffix List should be used for restricting cookie-setting
\r
325 * Without PSL {@link domainMatch()} will only prevent setting cookies for
\r
326 * top-level domains like '.com' or '.org'. However, it will not prevent
\r
327 * setting a cookie for '.co.uk' even though only third-level registrations
\r
328 * are possible in .uk domain.
\r
330 * With the List it is possible to find the highest level at which a domain
\r
331 * may be registered for a particular top-level domain and consequently
\r
332 * prevent cookies set for '.co.uk' or '.msk.ru'. The same list is used by
\r
333 * Firefox, Chrome and Opera browsers to restrict cookie setting.
\r
335 * Note that PSL is licensed differently to HTTP_Request2 package (refer to
\r
336 * the license information in public-suffix-list.php), so you can disable
\r
337 * its use if this is an issue for you.
\r
340 * @link http://publicsuffix.org/learn/
\r
342 public function usePublicSuffixList($useList)
\r
344 $this->useList = (bool)$useList;
\r
348 * Returns string representation of object
\r
351 * @see Serializable::serialize()
\r
353 public function serialize()
\r
355 $cookies = $this->getAll();
\r
356 if (!$this->serializeSession) {
\r
357 for ($i = count($cookies) - 1; $i >= 0; $i--) {
\r
358 if (empty($cookies[$i]['expires'])) {
\r
359 unset($cookies[$i]);
\r
363 return serialize(array(
\r
364 'cookies' => $cookies,
\r
365 'serializeSession' => $this->serializeSession,
\r
366 'useList' => $this->useList
\r
371 * Constructs the object from serialized string
\r
373 * @param string string representation
\r
374 * @see Serializable::unserialize()
\r
376 public function unserialize($serialized)
\r
378 $data = unserialize($serialized);
\r
379 $now = $this->now();
\r
380 $this->serializeSessionCookies($data['serializeSession']);
\r
381 $this->usePublicSuffixList($data['useList']);
\r
382 foreach ($data['cookies'] as $cookie) {
\r
383 if (!empty($cookie['expires']) && $cookie['expires'] <= $now) {
\r
386 if (!isset($this->cookies[$cookie['domain']])) {
\r
387 $this->cookies[$cookie['domain']] = array();
\r
389 if (!isset($this->cookies[$cookie['domain']][$cookie['path']])) {
\r
390 $this->cookies[$cookie['domain']][$cookie['path']] = array();
\r
392 $this->cookies[$cookie['domain']][$cookie['path']][$cookie['name']] = $cookie;
\r
397 * Checks whether a cookie domain matches a request host.
\r
399 * The method is used by {@link store()} to check for whether a document
\r
400 * at given URL can set a cookie with a given domain attribute and by
\r
401 * {@link getMatching()} to find cookies matching the request URL.
\r
403 * @param string request host
\r
404 * @param string cookie domain
\r
405 * @return bool match success
\r
407 public function domainMatch($requestHost, $cookieDomain)
\r
409 if ($requestHost == $cookieDomain) {
\r
412 // IP address, we require exact match
\r
413 if (preg_match('/^(?:\d{1,3}\.){3}\d{1,3}$/', $requestHost)) {
\r
416 if ('.' != $cookieDomain[0]) {
\r
417 $cookieDomain = '.' . $cookieDomain;
\r
419 // prevents setting cookies for '.com' and similar domains
\r
420 if (!$this->useList && substr_count($cookieDomain, '.') < 2
\r
421 || $this->useList && !self::getRegisteredDomain($cookieDomain)
\r
425 return substr('.' . $requestHost, -strlen($cookieDomain)) == $cookieDomain;
\r
429 * Removes subdomains to get the registered domain (the first after top-level)
\r
431 * The method will check Public Suffix List to find out where top-level
\r
432 * domain ends and registered domain starts. It will remove domain parts
\r
433 * to the left of registered one.
\r
435 * @param string domain name
\r
436 * @return string|bool registered domain, will return false if $domain is
\r
437 * either invalid or a TLD itself
\r
439 public static function getRegisteredDomain($domain)
\r
441 $domainParts = explode('.', ltrim($domain, '.'));
\r
443 // load the list if needed
\r
444 if (empty(self::$psl)) {
\r
445 $path = '@data_dir@' . DIRECTORY_SEPARATOR . 'HTTP_Request2';
\r
446 if (0 === strpos($path, '@' . 'data_dir@')) {
\r
447 $path = realpath(dirname(__FILE__) . DIRECTORY_SEPARATOR . '..'
\r
448 . DIRECTORY_SEPARATOR . 'data');
\r
450 self::$psl = include_once $path . DIRECTORY_SEPARATOR . 'public-suffix-list.php';
\r
453 if (!($result = self::checkDomainsList($domainParts, self::$psl))) {
\r
454 // known TLD, invalid domain name
\r
459 if (!strpos($result, '.')) {
\r
460 // fallback to checking that domain "has at least two dots"
\r
461 if (2 > ($count = count($domainParts))) {
\r
464 return $domainParts[$count - 2] . '.' . $domainParts[$count - 1];
\r
470 * Recursive helper method for {@link getRegisteredDomain()}
\r
472 * @param array remaining domain parts
\r
473 * @param mixed node in {@link HTTP_Request2_CookieJar::$psl} to check
\r
474 * @return string|null concatenated domain parts, null in case of error
\r
476 protected static function checkDomainsList(array $domainParts, $listNode)
\r
478 $sub = array_pop($domainParts);
\r
481 if (!is_array($listNode) || is_null($sub)
\r
482 || array_key_exists('!' . $sub, $listNode)
\r
486 } elseif (array_key_exists($sub, $listNode)) {
\r
487 $result = self::checkDomainsList($domainParts, $listNode[$sub]);
\r
489 } elseif (array_key_exists('*', $listNode)) {
\r
490 $result = self::checkDomainsList($domainParts, $listNode['*']);
\r
496 return (strlen($result) > 0) ? ($result . '.' . $sub) : null;
\r