diff --git a/library/Requests/Cookie.php b/library/Requests/Cookie.php index d97881766..b7c784c8e 100644 --- a/library/Requests/Cookie.php +++ b/library/Requests/Cookie.php @@ -44,6 +44,16 @@ class Requests_Cookie { */ public $flags = array(); + /** + * Reference time for relative calculations + * + * This is used in place of `time()` when calculating Max-Age expiration and + * checking time validity. + * + * @var int + */ + public $reference_time = 0; + /** * Create a new cookie object * @@ -51,7 +61,7 @@ class Requests_Cookie { * @param string $value * @param array $attributes Associative array of attribute data */ - public function __construct($name, $value, $attributes = array(), $flags = array()) { + public function __construct($name, $value, $attributes = array(), $flags = array(), $reference_time = null) { $this->name = $name; $this->value = $value; $this->attributes = $attributes; @@ -63,9 +73,40 @@ public function __construct($name, $value, $attributes = array(), $flags = array ); $this->flags = array_merge($default_flags, $flags); + $this->reference_time = time(); + if ($reference_time !== null) { + $this->reference_time = $reference_time; + } + $this->normalize(); } + /** + * Check if a cookie is expired. + * + * Checks the age against $this->reference_time to determine if the cookie + * is expired. + * + * @return boolean True if expired, false if time is valid. + */ + public function is_expired() { + // RFC6265, s. 4.1.2.2: + // If a cookie has both the Max-Age and the Expires attribute, the Max- + // Age attribute has precedence and controls the expiration date of the + // cookie. + if (isset($this->attributes['max-age'])) { + $max_age = $this->attributes['max-age']; + return $max_age < $this->reference_time; + } + + if (isset($this->attributes['expires'])) { + $expires = $this->attributes['expires']; + return $expires < $this->reference_time; + } + + return false; + } + /** * Check if a cookie is valid for a given URI * @@ -192,13 +233,10 @@ public function pathMatches($request_path) { public function normalize() { foreach ($this->attributes as $key => $value) { $orig_value = $value; - switch ($key) { - case 'domain': - // Domain normalization, as per RFC 6265 section 5.2.3 - if ($value[0] === '.') { - $value = substr($value, 1); - } - break; + $value = $this->normalizeAttribute($key, $value); + if ($value === null) { + unset($this->attributes[$key]); + continue; } if ($value !== $orig_value) { @@ -209,6 +247,64 @@ public function normalize() { return true; } + /** + * Parse an individual cookie attribute + * + * Handles parsing individual attributes from the cookie values. + * + * @param string $name Attribute name + * @param string|boolean $value Attribute value (string value, or true if empty/flag) + * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped) + */ + protected function normalizeAttribute($name, $value) { + switch (strtolower($name)) { + case 'expires': + // Expiration parsing, as per RFC 6265 section 5.2.1 + if (is_int($value)) { + return $value; + } + + $expiry_time = strtotime($value); + if ($expiry_time === false) { + return null; + } + + return $expiry_time; + + case 'max-age': + // Expiration parsing, as per RFC 6265 section 5.2.2 + if (is_int($value)) { + return $value; + } + + // Check that we have a valid age + if (!preg_match('/^-?\d+$/', $value)) { + return null; + } + + $delta_seconds = (int) $value; + if ($delta_seconds <= 0) { + $expiry_time = 0; + } + else { + $expiry_time = $this->reference_time + $delta_seconds; + } + + return $expiry_time; + + case 'domain': + // Domain normalization, as per RFC 6265 section 5.2.3 + if ($value[0] === '.') { + $value = substr($value, 1); + } + + return $value; + + default: + return $value; + } + } + /** * Format a cookie for a Cookie header * @@ -266,7 +362,7 @@ public function __toString() { * @param string Cookie header value (from a Set-Cookie header) * @return Requests_Cookie Parsed cookie object */ - public static function parse($string, $name = '') { + public static function parse($string, $name = '', $reference_time = null) { $parts = explode(';', $string); $kvparts = array_shift($parts); @@ -307,7 +403,7 @@ public static function parse($string, $name = '') { } } - return new Requests_Cookie($name, $value, $attributes); + return new Requests_Cookie($name, $value, $attributes, array(), $reference_time); } /** @@ -316,7 +412,7 @@ public static function parse($string, $name = '') { * @param Requests_Response_Headers $headers * @return array */ - public static function parseFromHeaders(Requests_Response_Headers $headers, Requests_IRI $origin = null) { + public static function parseFromHeaders(Requests_Response_Headers $headers, Requests_IRI $origin = null, $reference_time = null) { $cookie_headers = $headers->getValues('Set-Cookie'); if (empty($cookie_headers)) { return array(); @@ -324,15 +420,15 @@ public static function parseFromHeaders(Requests_Response_Headers $headers, Requ $cookies = array(); foreach ($cookie_headers as $header) { - $parsed = self::parse($header); + $parsed = self::parse($header, '', $reference_time); // Default domain/path attributes if (empty($parsed->attributes['domain']) && !empty($origin)) { $parsed->attributes['domain'] = $origin->host; - $parsed->flags['host-only'] = false; + $parsed->flags['host-only'] = true; } else { - $parsed->flags['host-only'] = true; + $parsed->flags['host-only'] = false; } $path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/'); @@ -362,7 +458,7 @@ public static function parseFromHeaders(Requests_Response_Headers $headers, Requ } // Reject invalid cookie domains - if (!$parsed->domainMatches($origin->host)) { + if (!empty($origin) && !$parsed->domainMatches($origin->host)) { continue; } diff --git a/library/Requests/Cookie/Jar.php b/library/Requests/Cookie/Jar.php index 82c332d2d..901219a2e 100644 --- a/library/Requests/Cookie/Jar.php +++ b/library/Requests/Cookie/Jar.php @@ -131,6 +131,11 @@ public function before_request($url, &$headers, &$data, &$type, &$options) { foreach ($this->cookies as $key => $cookie) { $cookie = $this->normalizeCookie($cookie, $key); + // Skip expired cookies + if ($cookie->is_expired()) { + continue; + } + if ( $cookie->domainMatches( $url->host ) ) { $cookies[] = $cookie->formatForHeader(); } diff --git a/tests/Cookies.php b/tests/Cookies.php index 0f29fc77d..b7ea57220 100755 --- a/tests/Cookies.php +++ b/tests/Cookies.php @@ -124,6 +124,23 @@ public function testSendingCookie() { $this->assertEquals('testvalue1', $data['requests-testcookie1']); } + /** + * @depends testSendingCookie + */ + public function testCookieExpiration() { + $options = array( + 'follow_redirects' => true, + ); + $url = httpbin('/cookies/set/testcookie/testvalue'); + $url .= '?expiry=1'; + + $response = Requests::get($url, array(), $options); + $response->throw_for_status(); + + $data = json_decode($response->body, true); + $this->assertEmpty($data['cookies']); + } + public function testSendingCookieWithJar() { $cookies = new Requests_Cookie_Jar(array( 'requests-testcookie1' => 'testvalue1', @@ -222,6 +239,7 @@ public function testDomainMatch($original, $check, $matches, $domain_matches) { public function pathMatchProvider() { return array( + array('/', '', true), array('/', '/', true), array('/', '/test', true), @@ -346,4 +364,279 @@ public function testUrlMatchManuallySet() { $this->assertTrue($cookie->uriMatches(new Requests_IRI('http://example.net/test'))); $this->assertTrue($cookie->uriMatches(new Requests_IRI('http://example.net/test/'))); } + + public static function parseResultProvider() { + return array( + // Basic parsing + array( + 'foo=bar', + array( 'name' => 'foo', 'value' => 'bar' ), + ), + array( + 'bar', + array( 'name' => '', 'value' => 'bar' ), + ), + + // Expiration + // RFC 822, updated by RFC 1123 + array( + 'foo=bar; Expires=Thu, 5-Dec-2013 04:50:12 GMT', + array( 'expired' => true ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2013 ) ), + ), + array( + 'foo=bar; Expires=Fri, 5-Dec-2014 04:50:12 GMT', + array( 'expired' => false ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2014 ) ), + ), + // RFC 850, obsoleted by RFC 1036 + array( + 'foo=bar; Expires=Thursday, 5-Dec-2013 04:50:12 GMT', + array( 'expired' => true ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2013 ) ), + ), + array( + 'foo=bar; Expires=Friday, 5-Dec-2014 04:50:12 GMT', + array( 'expired' => false ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2014 ) ), + ), + // asctime() + array( + 'foo=bar; Expires=Thu Dec 5 04:50:12 2013', + array( 'expired' => true ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2013 ) ), + ), + array( + 'foo=bar; Expires=Fri Dec 5 04:50:12 2014', + array( 'expired' => false ), + array( 'expires' => gmmktime( 4, 50, 12, 12, 5, 2014 ) ), + ), + array( + // Invalid + 'foo=bar; Expires=never', + array(), + array( 'expires' => null ), + ), + + // Max-Age + array( + 'foo=bar; Max-Age=10', + array( 'expired' => false ), + array( 'max-age' => gmmktime( 0, 0, 10, 1, 1, 2014 ) ), + ), + array( + 'foo=bar; Max-Age=3660', + array( 'expired' => false ), + array( 'max-age' => gmmktime( 1, 1, 0, 1, 1, 2014 ) ), + ), + array( + 'foo=bar; Max-Age=0', + array( 'expired' => true ), + array( 'max-age' => 0 ), + ), + array( + 'foo=bar; Max-Age=-1000', + array( 'expired' => true ), + array( 'max-age' => 0 ), + ), + array( + // Invalid (non-digit character) + 'foo=bar; Max-Age=1e6', + array( 'expired' => false ), + array( 'max-age' => null ), + ) + ); + } + + protected function check_parsed_cookie($cookie, $expected, $expected_attributes, $expected_flags = array()) { + if (isset($expected['name'])) { + $this->assertEquals($expected['name'], $cookie->name); + } + if (isset($expected['value'])) { + $this->assertEquals($expected['value'], $cookie->value); + } + if (isset($expected['expired'])) { + $this->assertEquals($expected['expired'], $cookie->is_expired()); + } + if (isset($expected_attributes)) { + foreach ($expected_attributes as $attr_key => $attr_val) { + $this->assertEquals($attr_val, $cookie->attributes[$attr_key], "$attr_key should match supplied"); + } + } + if (isset($expected_flags)) { + foreach ($expected_flags as $flag_key => $flag_val) { + $this->assertEquals($flag_val, $cookie->flags[$flag_key], "$flag_key should match supplied"); + } + } + } + + /** + * @dataProvider parseResultProvider + */ + public function testParsingHeader($header, $expected, $expected_attributes = array(), $expected_flags = array()) { + // Set the reference time to 2014-01-01 00:00:00 + $reference_time = gmmktime( 0, 0, 0, 1, 1, 2014 ); + + $cookie = Requests_Cookie::parse($header, null, $reference_time); + $this->check_parsed_cookie($cookie, $expected, $expected_attributes); + } + + /** + * Double-normalizes the cookie data to ensure we catch any issues there + * + * @dataProvider parseResultProvider + */ + public function testParsingHeaderDouble($header, $expected, $expected_attributes = array(), $expected_flags = array()) { + // Set the reference time to 2014-01-01 00:00:00 + $reference_time = gmmktime( 0, 0, 0, 1, 1, 2014 ); + + $cookie = Requests_Cookie::parse($header, null, $reference_time); + + // Normalize the value again + $cookie->normalize(); + + $this->check_parsed_cookie($cookie, $expected, $expected_attributes, $expected_flags); + } + + /** + * @dataProvider parseResultProvider + */ + public function testParsingHeaderObject($header, $expected, $expected_attributes = array(), $expected_flags = array()) { + $headers = new Requests_Response_Headers(); + $headers['Set-Cookie'] = $header; + + // Set the reference time to 2014-01-01 00:00:00 + $reference_time = gmmktime( 0, 0, 0, 1, 1, 2014 ); + + $parsed = Requests_Cookie::parseFromHeaders($headers, null, $reference_time); + $this->assertCount(1, $parsed); + + $cookie = reset($parsed); + $this->check_parsed_cookie($cookie, $expected, $expected_attributes); + } + + public function parseFromHeadersProvider() { + return array( + # Varying origin path + array( + 'name=value', + 'http://example.com/', + array(), + array( 'path' => '/' ), + array( 'host-only' => true ), + ), + array( + 'name=value', + 'http://example.com/test', + array(), + array( 'path' => '/' ), + array( 'host-only' => true ), + ), + array( + 'name=value', + 'http://example.com/test/', + array(), + array( 'path' => '/test' ), + array( 'host-only' => true ), + ), + array( + 'name=value', + 'http://example.com/test/abc', + array(), + array( 'path' => '/test' ), + array( 'host-only' => true ), + ), + array( + 'name=value', + 'http://example.com/test/abc/', + array(), + array( 'path' => '/test/abc' ), + array( 'host-only' => true ), + ), + + # With specified path + array( + 'name=value; path=/', + 'http://example.com/', + array(), + array( 'path' => '/' ), + array( 'host-only' => true ), + ), + array( + 'name=value; path=/test', + 'http://example.com/', + array(), + array( 'path' => '/test' ), + array( 'host-only' => true ), + ), + array( + 'name=value; path=/test/', + 'http://example.com/', + array(), + array( 'path' => '/test/' ), + array( 'host-only' => true ), + ), + + # Invalid path + array( + 'name=value; path=yolo', + 'http://example.com/', + array(), + array( 'path' => '/' ), + array( 'host-only' => true ), + ), + array( + 'name=value; path=yolo', + 'http://example.com/test/', + array(), + array( 'path' => '/test' ), + array( 'host-only' => true ), + ), + + # Cross-origin cookies, reject! + array( + 'name=value; domain=example.org', + 'http://example.com/', + array( 'invalid' => false ), + ), + + # Subdomain cookies + array( + 'name=value; domain=test.example.com', + 'http://test.example.com/', + array(), + array( 'domain' => 'test.example.com' ), + array( 'host-only' => false ) + ), + array( + 'name=value; domain=example.com', + 'http://test.example.com/', + array(), + array( 'domain' => 'example.com' ), + array( 'host-only' => false ) + ), + ); + } + + /** + * @dataProvider parseFromHeadersProvider + */ + public function testParsingHeaderWithOrigin($header, $origin, $expected, $expected_attributes = array(), $expected_flags = array()) { + $origin = new Requests_IRI($origin); + $headers = new Requests_Response_Headers(); + $headers['Set-Cookie'] = $header; + + // Set the reference time to 2014-01-01 00:00:00 + $reference_time = gmmktime( 0, 0, 0, 1, 1, 2014 ); + + $parsed = Requests_Cookie::parseFromHeaders($headers, $origin, $reference_time); + if (isset($expected['invalid'])) { + $this->assertCount(0, $parsed); + return; + } + $this->assertCount(1, $parsed); + + $cookie = reset($parsed); + $this->check_parsed_cookie($cookie, $expected, $expected_attributes, $expected_flags); + } } \ No newline at end of file