1<?php 2 3namespace GuzzleHttp\Cookie; 4 5/** 6 * Set-Cookie object 7 */ 8class SetCookie 9{ 10 /** 11 * @var array 12 */ 13 private static $defaults = [ 14 'Name' => null, 15 'Value' => null, 16 'Domain' => null, 17 'Path' => '/', 18 'Max-Age' => null, 19 'Expires' => null, 20 'Secure' => false, 21 'Discard' => false, 22 'HttpOnly' => false, 23 ]; 24 25 /** 26 * @var array Cookie data 27 */ 28 private $data; 29 30 /** 31 * Create a new SetCookie object from a string. 32 * 33 * @param string $cookie Set-Cookie header string 34 */ 35 public static function fromString(string $cookie): self 36 { 37 // Create the default return array 38 $data = self::$defaults; 39 // Explode the cookie string using a series of semicolons 40 $pieces = \array_filter(\array_map('trim', \explode(';', $cookie))); 41 // The name of the cookie (first kvp) must exist and include an equal sign. 42 if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) { 43 return new self($data); 44 } 45 46 // Add the cookie pieces into the parsed data array 47 foreach ($pieces as $part) { 48 $cookieParts = \explode('=', $part, 2); 49 $key = \trim($cookieParts[0]); 50 $value = isset($cookieParts[1]) 51 ? \trim($cookieParts[1], " \n\r\t\0\x0B") 52 : true; 53 54 // Only check for non-cookies when cookies have been found 55 if (!isset($data['Name'])) { 56 $data['Name'] = $key; 57 $data['Value'] = $value; 58 } else { 59 foreach (\array_keys(self::$defaults) as $search) { 60 if (!\strcasecmp($search, $key)) { 61 if ($search === 'Max-Age') { 62 if (is_numeric($value)) { 63 $data[$search] = (int) $value; 64 } 65 } else { 66 $data[$search] = $value; 67 } 68 continue 2; 69 } 70 } 71 $data[$key] = $value; 72 } 73 } 74 75 return new self($data); 76 } 77 78 /** 79 * @param array $data Array of cookie data provided by a Cookie parser 80 */ 81 public function __construct(array $data = []) 82 { 83 $this->data = self::$defaults; 84 85 if (isset($data['Name'])) { 86 $this->setName($data['Name']); 87 } 88 89 if (isset($data['Value'])) { 90 $this->setValue($data['Value']); 91 } 92 93 if (isset($data['Domain'])) { 94 $this->setDomain($data['Domain']); 95 } 96 97 if (isset($data['Path'])) { 98 $this->setPath($data['Path']); 99 } 100 101 if (isset($data['Max-Age'])) { 102 $this->setMaxAge($data['Max-Age']); 103 } 104 105 if (isset($data['Expires'])) { 106 $this->setExpires($data['Expires']); 107 } 108 109 if (isset($data['Secure'])) { 110 $this->setSecure($data['Secure']); 111 } 112 113 if (isset($data['Discard'])) { 114 $this->setDiscard($data['Discard']); 115 } 116 117 if (isset($data['HttpOnly'])) { 118 $this->setHttpOnly($data['HttpOnly']); 119 } 120 121 // Set the remaining values that don't have extra validation logic 122 foreach (array_diff(array_keys($data), array_keys(self::$defaults)) as $key) { 123 $this->data[$key] = $data[$key]; 124 } 125 126 // Extract the Expires value and turn it into a UNIX timestamp if needed 127 if (!$this->getExpires() && $this->getMaxAge()) { 128 // Calculate the Expires date 129 $this->setExpires(\time() + $this->getMaxAge()); 130 } elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) { 131 $this->setExpires($expires); 132 } 133 } 134 135 public function __toString() 136 { 137 $str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; '; 138 foreach ($this->data as $k => $v) { 139 if ($k !== 'Name' && $k !== 'Value' && $v !== null && $v !== false) { 140 if ($k === 'Expires') { 141 $str .= 'Expires='.\gmdate('D, d M Y H:i:s \G\M\T', $v).'; '; 142 } else { 143 $str .= ($v === true ? $k : "{$k}={$v}").'; '; 144 } 145 } 146 } 147 148 return \rtrim($str, '; '); 149 } 150 151 public function toArray(): array 152 { 153 return $this->data; 154 } 155 156 /** 157 * Get the cookie name. 158 * 159 * @return string 160 */ 161 public function getName() 162 { 163 return $this->data['Name']; 164 } 165 166 /** 167 * Set the cookie name. 168 * 169 * @param string $name Cookie name 170 */ 171 public function setName($name): void 172 { 173 if (!is_string($name)) { 174 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 175 } 176 177 $this->data['Name'] = (string) $name; 178 } 179 180 /** 181 * Get the cookie value. 182 * 183 * @return string|null 184 */ 185 public function getValue() 186 { 187 return $this->data['Value']; 188 } 189 190 /** 191 * Set the cookie value. 192 * 193 * @param string $value Cookie value 194 */ 195 public function setValue($value): void 196 { 197 if (!is_string($value)) { 198 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 199 } 200 201 $this->data['Value'] = (string) $value; 202 } 203 204 /** 205 * Get the domain. 206 * 207 * @return string|null 208 */ 209 public function getDomain() 210 { 211 return $this->data['Domain']; 212 } 213 214 /** 215 * Set the domain of the cookie. 216 * 217 * @param string|null $domain 218 */ 219 public function setDomain($domain): void 220 { 221 if (!is_string($domain) && null !== $domain) { 222 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 223 } 224 225 $this->data['Domain'] = null === $domain ? null : (string) $domain; 226 } 227 228 /** 229 * Get the path. 230 * 231 * @return string 232 */ 233 public function getPath() 234 { 235 return $this->data['Path']; 236 } 237 238 /** 239 * Set the path of the cookie. 240 * 241 * @param string $path Path of the cookie 242 */ 243 public function setPath($path): void 244 { 245 if (!is_string($path)) { 246 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 247 } 248 249 $this->data['Path'] = (string) $path; 250 } 251 252 /** 253 * Maximum lifetime of the cookie in seconds. 254 * 255 * @return int|null 256 */ 257 public function getMaxAge() 258 { 259 return null === $this->data['Max-Age'] ? null : (int) $this->data['Max-Age']; 260 } 261 262 /** 263 * Set the max-age of the cookie. 264 * 265 * @param int|null $maxAge Max age of the cookie in seconds 266 */ 267 public function setMaxAge($maxAge): void 268 { 269 if (!is_int($maxAge) && null !== $maxAge) { 270 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 271 } 272 273 $this->data['Max-Age'] = $maxAge === null ? null : (int) $maxAge; 274 } 275 276 /** 277 * The UNIX timestamp when the cookie Expires. 278 * 279 * @return string|int|null 280 */ 281 public function getExpires() 282 { 283 return $this->data['Expires']; 284 } 285 286 /** 287 * Set the unix timestamp for which the cookie will expire. 288 * 289 * @param int|string|null $timestamp Unix timestamp or any English textual datetime description. 290 */ 291 public function setExpires($timestamp): void 292 { 293 if (!is_int($timestamp) && !is_string($timestamp) && null !== $timestamp) { 294 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int, string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 295 } 296 297 $this->data['Expires'] = null === $timestamp ? null : (\is_numeric($timestamp) ? (int) $timestamp : \strtotime((string) $timestamp)); 298 } 299 300 /** 301 * Get whether or not this is a secure cookie. 302 * 303 * @return bool 304 */ 305 public function getSecure() 306 { 307 return $this->data['Secure']; 308 } 309 310 /** 311 * Set whether or not the cookie is secure. 312 * 313 * @param bool $secure Set to true or false if secure 314 */ 315 public function setSecure($secure): void 316 { 317 if (!is_bool($secure)) { 318 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 319 } 320 321 $this->data['Secure'] = (bool) $secure; 322 } 323 324 /** 325 * Get whether or not this is a session cookie. 326 * 327 * @return bool|null 328 */ 329 public function getDiscard() 330 { 331 return $this->data['Discard']; 332 } 333 334 /** 335 * Set whether or not this is a session cookie. 336 * 337 * @param bool $discard Set to true or false if this is a session cookie 338 */ 339 public function setDiscard($discard): void 340 { 341 if (!is_bool($discard)) { 342 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 343 } 344 345 $this->data['Discard'] = (bool) $discard; 346 } 347 348 /** 349 * Get whether or not this is an HTTP only cookie. 350 * 351 * @return bool 352 */ 353 public function getHttpOnly() 354 { 355 return $this->data['HttpOnly']; 356 } 357 358 /** 359 * Set whether or not this is an HTTP only cookie. 360 * 361 * @param bool $httpOnly Set to true or false if this is HTTP only 362 */ 363 public function setHttpOnly($httpOnly): void 364 { 365 if (!is_bool($httpOnly)) { 366 trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__); 367 } 368 369 $this->data['HttpOnly'] = (bool) $httpOnly; 370 } 371 372 /** 373 * Check if the cookie matches a path value. 374 * 375 * A request-path path-matches a given cookie-path if at least one of 376 * the following conditions holds: 377 * 378 * - The cookie-path and the request-path are identical. 379 * - The cookie-path is a prefix of the request-path, and the last 380 * character of the cookie-path is %x2F ("/"). 381 * - The cookie-path is a prefix of the request-path, and the first 382 * character of the request-path that is not included in the cookie- 383 * path is a %x2F ("/") character. 384 * 385 * @param string $requestPath Path to check against 386 */ 387 public function matchesPath(string $requestPath): bool 388 { 389 $cookiePath = $this->getPath(); 390 391 // Match on exact matches or when path is the default empty "/" 392 if ($cookiePath === '/' || $cookiePath == $requestPath) { 393 return true; 394 } 395 396 // Ensure that the cookie-path is a prefix of the request path. 397 if (0 !== \strpos($requestPath, $cookiePath)) { 398 return false; 399 } 400 401 // Match if the last character of the cookie-path is "/" 402 if (\substr($cookiePath, -1, 1) === '/') { 403 return true; 404 } 405 406 // Match if the first character not included in cookie path is "/" 407 return \substr($requestPath, \strlen($cookiePath), 1) === '/'; 408 } 409 410 /** 411 * Check if the cookie matches a domain value. 412 * 413 * @param string $domain Domain to check against 414 */ 415 public function matchesDomain(string $domain): bool 416 { 417 $cookieDomain = $this->getDomain(); 418 if (null === $cookieDomain) { 419 return true; 420 } 421 422 // Remove the leading '.' as per spec in RFC 6265. 423 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3 424 $cookieDomain = \ltrim(\strtolower($cookieDomain), '.'); 425 426 $domain = \strtolower($domain); 427 428 // Domain not set or exact match. 429 if ('' === $cookieDomain || $domain === $cookieDomain) { 430 return true; 431 } 432 433 // Matching the subdomain according to RFC 6265. 434 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3 435 if (\filter_var($domain, \FILTER_VALIDATE_IP)) { 436 return false; 437 } 438 439 return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain); 440 } 441 442 /** 443 * Check if the cookie is expired. 444 */ 445 public function isExpired(): bool 446 { 447 return $this->getExpires() !== null && \time() > $this->getExpires(); 448 } 449 450 /** 451 * Check if the cookie is valid according to RFC 6265. 452 * 453 * @return bool|string Returns true if valid or an error message if invalid 454 */ 455 public function validate() 456 { 457 $name = $this->getName(); 458 if ($name === '') { 459 return 'The cookie name must not be empty'; 460 } 461 462 // Check if any of the invalid characters are present in the cookie name 463 if (\preg_match( 464 '/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/', 465 $name 466 )) { 467 return 'Cookie name must not contain invalid characters: ASCII ' 468 .'Control characters (0-31;127), space, tab and the ' 469 .'following characters: ()<>@,;:\"/?={}'; 470 } 471 472 // Value must not be null. 0 and empty string are valid. Empty strings 473 // are technically against RFC 6265, but known to happen in the wild. 474 $value = $this->getValue(); 475 if ($value === null) { 476 return 'The cookie value must not be empty'; 477 } 478 479 // Domains must not be empty, but can be 0. "0" is not a valid internet 480 // domain, but may be used as server name in a private network. 481 $domain = $this->getDomain(); 482 if ($domain === null || $domain === '') { 483 return 'The cookie domain must not be empty'; 484 } 485 486 return true; 487 } 488} 489