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?>