Cookie.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <?php
  2. /**
  3. * Cookie storage object
  4. *
  5. * @package Requests\Cookies
  6. */
  7. namespace WpOrg\Requests;
  8. use WpOrg\Requests\Exception\InvalidArgument;
  9. use WpOrg\Requests\Iri;
  10. use WpOrg\Requests\Response\Headers;
  11. use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
  12. use WpOrg\Requests\Utility\InputValidator;
  13. /**
  14. * Cookie storage object
  15. *
  16. * @package Requests\Cookies
  17. */
  18. class Cookie {
  19. /**
  20. * Cookie name.
  21. *
  22. * @var string
  23. */
  24. public $name;
  25. /**
  26. * Cookie value.
  27. *
  28. * @var string
  29. */
  30. public $value;
  31. /**
  32. * Cookie attributes
  33. *
  34. * Valid keys are (currently) path, domain, expires, max-age, secure and
  35. * httponly.
  36. *
  37. * @var \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array Array-like object
  38. */
  39. public $attributes = [];
  40. /**
  41. * Cookie flags
  42. *
  43. * Valid keys are (currently) creation, last-access, persistent and
  44. * host-only.
  45. *
  46. * @var array
  47. */
  48. public $flags = [];
  49. /**
  50. * Reference time for relative calculations
  51. *
  52. * This is used in place of `time()` when calculating Max-Age expiration and
  53. * checking time validity.
  54. *
  55. * @var int
  56. */
  57. public $reference_time = 0;
  58. /**
  59. * Create a new cookie object
  60. *
  61. * @param string $name
  62. * @param string $value
  63. * @param array|\WpOrg\Requests\Utility\CaseInsensitiveDictionary $attributes Associative array of attribute data
  64. * @param array $flags
  65. * @param int|null $reference_time
  66. *
  67. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string.
  68. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $value argument is not a string.
  69. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $attributes argument is not an array or iterable object with array access.
  70. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $flags argument is not an array.
  71. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $reference_time argument is not an integer or null.
  72. */
  73. public function __construct($name, $value, $attributes = [], $flags = [], $reference_time = null) {
  74. if (is_string($name) === false) {
  75. throw InvalidArgument::create(1, '$name', 'string', gettype($name));
  76. }
  77. if (is_string($value) === false) {
  78. throw InvalidArgument::create(2, '$value', 'string', gettype($value));
  79. }
  80. if (InputValidator::has_array_access($attributes) === false || InputValidator::is_iterable($attributes) === false) {
  81. throw InvalidArgument::create(3, '$attributes', 'array|ArrayAccess&Traversable', gettype($attributes));
  82. }
  83. if (is_array($flags) === false) {
  84. throw InvalidArgument::create(4, '$flags', 'array', gettype($flags));
  85. }
  86. if ($reference_time !== null && is_int($reference_time) === false) {
  87. throw InvalidArgument::create(5, '$reference_time', 'integer|null', gettype($reference_time));
  88. }
  89. $this->name = $name;
  90. $this->value = $value;
  91. $this->attributes = $attributes;
  92. $default_flags = [
  93. 'creation' => time(),
  94. 'last-access' => time(),
  95. 'persistent' => false,
  96. 'host-only' => true,
  97. ];
  98. $this->flags = array_merge($default_flags, $flags);
  99. $this->reference_time = time();
  100. if ($reference_time !== null) {
  101. $this->reference_time = $reference_time;
  102. }
  103. $this->normalize();
  104. }
  105. /**
  106. * Get the cookie value
  107. *
  108. * Attributes and other data can be accessed via methods.
  109. */
  110. public function __toString() {
  111. return $this->value;
  112. }
  113. /**
  114. * Check if a cookie is expired.
  115. *
  116. * Checks the age against $this->reference_time to determine if the cookie
  117. * is expired.
  118. *
  119. * @return boolean True if expired, false if time is valid.
  120. */
  121. public function is_expired() {
  122. // RFC6265, s. 4.1.2.2:
  123. // If a cookie has both the Max-Age and the Expires attribute, the Max-
  124. // Age attribute has precedence and controls the expiration date of the
  125. // cookie.
  126. if (isset($this->attributes['max-age'])) {
  127. $max_age = $this->attributes['max-age'];
  128. return $max_age < $this->reference_time;
  129. }
  130. if (isset($this->attributes['expires'])) {
  131. $expires = $this->attributes['expires'];
  132. return $expires < $this->reference_time;
  133. }
  134. return false;
  135. }
  136. /**
  137. * Check if a cookie is valid for a given URI
  138. *
  139. * @param \WpOrg\Requests\Iri $uri URI to check
  140. * @return boolean Whether the cookie is valid for the given URI
  141. */
  142. public function uri_matches(Iri $uri) {
  143. if (!$this->domain_matches($uri->host)) {
  144. return false;
  145. }
  146. if (!$this->path_matches($uri->path)) {
  147. return false;
  148. }
  149. return empty($this->attributes['secure']) || $uri->scheme === 'https';
  150. }
  151. /**
  152. * Check if a cookie is valid for a given domain
  153. *
  154. * @param string $domain Domain to check
  155. * @return boolean Whether the cookie is valid for the given domain
  156. */
  157. public function domain_matches($domain) {
  158. if (is_string($domain) === false) {
  159. return false;
  160. }
  161. if (!isset($this->attributes['domain'])) {
  162. // Cookies created manually; cookies created by Requests will set
  163. // the domain to the requested domain
  164. return true;
  165. }
  166. $cookie_domain = $this->attributes['domain'];
  167. if ($cookie_domain === $domain) {
  168. // The cookie domain and the passed domain are identical.
  169. return true;
  170. }
  171. // If the cookie is marked as host-only and we don't have an exact
  172. // match, reject the cookie
  173. if ($this->flags['host-only'] === true) {
  174. return false;
  175. }
  176. if (strlen($domain) <= strlen($cookie_domain)) {
  177. // For obvious reasons, the cookie domain cannot be a suffix if the passed domain
  178. // is shorter than the cookie domain
  179. return false;
  180. }
  181. if (substr($domain, -1 * strlen($cookie_domain)) !== $cookie_domain) {
  182. // The cookie domain should be a suffix of the passed domain.
  183. return false;
  184. }
  185. $prefix = substr($domain, 0, strlen($domain) - strlen($cookie_domain));
  186. if (substr($prefix, -1) !== '.') {
  187. // The last character of the passed domain that is not included in the
  188. // domain string should be a %x2E (".") character.
  189. return false;
  190. }
  191. // The passed domain should be a host name (i.e., not an IP address).
  192. return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $domain);
  193. }
  194. /**
  195. * Check if a cookie is valid for a given path
  196. *
  197. * From the path-match check in RFC 6265 section 5.1.4
  198. *
  199. * @param string $request_path Path to check
  200. * @return boolean Whether the cookie is valid for the given path
  201. */
  202. public function path_matches($request_path) {
  203. if (empty($request_path)) {
  204. // Normalize empty path to root
  205. $request_path = '/';
  206. }
  207. if (!isset($this->attributes['path'])) {
  208. // Cookies created manually; cookies created by Requests will set
  209. // the path to the requested path
  210. return true;
  211. }
  212. if (is_scalar($request_path) === false) {
  213. return false;
  214. }
  215. $cookie_path = $this->attributes['path'];
  216. if ($cookie_path === $request_path) {
  217. // The cookie-path and the request-path are identical.
  218. return true;
  219. }
  220. if (strlen($request_path) > strlen($cookie_path) && substr($request_path, 0, strlen($cookie_path)) === $cookie_path) {
  221. if (substr($cookie_path, -1) === '/') {
  222. // The cookie-path is a prefix of the request-path, and the last
  223. // character of the cookie-path is %x2F ("/").
  224. return true;
  225. }
  226. if (substr($request_path, strlen($cookie_path), 1) === '/') {
  227. // The cookie-path is a prefix of the request-path, and the
  228. // first character of the request-path that is not included in
  229. // the cookie-path is a %x2F ("/") character.
  230. return true;
  231. }
  232. }
  233. return false;
  234. }
  235. /**
  236. * Normalize cookie and attributes
  237. *
  238. * @return boolean Whether the cookie was successfully normalized
  239. */
  240. public function normalize() {
  241. foreach ($this->attributes as $key => $value) {
  242. $orig_value = $value;
  243. $value = $this->normalize_attribute($key, $value);
  244. if ($value === null) {
  245. unset($this->attributes[$key]);
  246. continue;
  247. }
  248. if ($value !== $orig_value) {
  249. $this->attributes[$key] = $value;
  250. }
  251. }
  252. return true;
  253. }
  254. /**
  255. * Parse an individual cookie attribute
  256. *
  257. * Handles parsing individual attributes from the cookie values.
  258. *
  259. * @param string $name Attribute name
  260. * @param string|boolean $value Attribute value (string value, or true if empty/flag)
  261. * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped)
  262. */
  263. protected function normalize_attribute($name, $value) {
  264. switch (strtolower($name)) {
  265. case 'expires':
  266. // Expiration parsing, as per RFC 6265 section 5.2.1
  267. if (is_int($value)) {
  268. return $value;
  269. }
  270. $expiry_time = strtotime($value);
  271. if ($expiry_time === false) {
  272. return null;
  273. }
  274. return $expiry_time;
  275. case 'max-age':
  276. // Expiration parsing, as per RFC 6265 section 5.2.2
  277. if (is_int($value)) {
  278. return $value;
  279. }
  280. // Check that we have a valid age
  281. if (!preg_match('/^-?\d+$/', $value)) {
  282. return null;
  283. }
  284. $delta_seconds = (int) $value;
  285. if ($delta_seconds <= 0) {
  286. $expiry_time = 0;
  287. } else {
  288. $expiry_time = $this->reference_time + $delta_seconds;
  289. }
  290. return $expiry_time;
  291. case 'domain':
  292. // Domains are not required as per RFC 6265 section 5.2.3
  293. if (empty($value)) {
  294. return null;
  295. }
  296. // Domain normalization, as per RFC 6265 section 5.2.3
  297. if ($value[0] === '.') {
  298. $value = substr($value, 1);
  299. }
  300. return $value;
  301. default:
  302. return $value;
  303. }
  304. }
  305. /**
  306. * Format a cookie for a Cookie header
  307. *
  308. * This is used when sending cookies to a server.
  309. *
  310. * @return string Cookie formatted for Cookie header
  311. */
  312. public function format_for_header() {
  313. return sprintf('%s=%s', $this->name, $this->value);
  314. }
  315. /**
  316. * Format a cookie for a Set-Cookie header
  317. *
  318. * This is used when sending cookies to clients. This isn't really
  319. * applicable to client-side usage, but might be handy for debugging.
  320. *
  321. * @return string Cookie formatted for Set-Cookie header
  322. */
  323. public function format_for_set_cookie() {
  324. $header_value = $this->format_for_header();
  325. if (!empty($this->attributes)) {
  326. $parts = [];
  327. foreach ($this->attributes as $key => $value) {
  328. // Ignore non-associative attributes
  329. if (is_numeric($key)) {
  330. $parts[] = $value;
  331. } else {
  332. $parts[] = sprintf('%s=%s', $key, $value);
  333. }
  334. }
  335. $header_value .= '; ' . implode('; ', $parts);
  336. }
  337. return $header_value;
  338. }
  339. /**
  340. * Parse a cookie string into a cookie object
  341. *
  342. * Based on Mozilla's parsing code in Firefox and related projects, which
  343. * is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265
  344. * specifies some of this handling, but not in a thorough manner.
  345. *
  346. * @param string $cookie_header Cookie header value (from a Set-Cookie header)
  347. * @param string $name
  348. * @param int|null $reference_time
  349. * @return \WpOrg\Requests\Cookie Parsed cookie object
  350. *
  351. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cookie_header argument is not a string.
  352. * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string.
  353. */
  354. public static function parse($cookie_header, $name = '', $reference_time = null) {
  355. if (is_string($cookie_header) === false) {
  356. throw InvalidArgument::create(1, '$cookie_header', 'string', gettype($cookie_header));
  357. }
  358. if (is_string($name) === false) {
  359. throw InvalidArgument::create(2, '$name', 'string', gettype($name));
  360. }
  361. $parts = explode(';', $cookie_header);
  362. $kvparts = array_shift($parts);
  363. if (!empty($name)) {
  364. $value = $cookie_header;
  365. } elseif (strpos($kvparts, '=') === false) {
  366. // Some sites might only have a value without the equals separator.
  367. // Deviate from RFC 6265 and pretend it was actually a blank name
  368. // (`=foo`)
  369. //
  370. // https://bugzilla.mozilla.org/show_bug.cgi?id=169091
  371. $name = '';
  372. $value = $kvparts;
  373. } else {
  374. list($name, $value) = explode('=', $kvparts, 2);
  375. }
  376. $name = trim($name);
  377. $value = trim($value);
  378. // Attribute keys are handled case-insensitively
  379. $attributes = new CaseInsensitiveDictionary();
  380. if (!empty($parts)) {
  381. foreach ($parts as $part) {
  382. if (strpos($part, '=') === false) {
  383. $part_key = $part;
  384. $part_value = true;
  385. } else {
  386. list($part_key, $part_value) = explode('=', $part, 2);
  387. $part_value = trim($part_value);
  388. }
  389. $part_key = trim($part_key);
  390. $attributes[$part_key] = $part_value;
  391. }
  392. }
  393. return new static($name, $value, $attributes, [], $reference_time);
  394. }
  395. /**
  396. * Parse all Set-Cookie headers from request headers
  397. *
  398. * @param \WpOrg\Requests\Response\Headers $headers Headers to parse from
  399. * @param \WpOrg\Requests\Iri|null $origin URI for comparing cookie origins
  400. * @param int|null $time Reference time for expiration calculation
  401. * @return array
  402. */
  403. public static function parse_from_headers(Headers $headers, Iri $origin = null, $time = null) {
  404. $cookie_headers = $headers->getValues('Set-Cookie');
  405. if (empty($cookie_headers)) {
  406. return [];
  407. }
  408. $cookies = [];
  409. foreach ($cookie_headers as $header) {
  410. $parsed = self::parse($header, '', $time);
  411. // Default domain/path attributes
  412. if (empty($parsed->attributes['domain']) && !empty($origin)) {
  413. $parsed->attributes['domain'] = $origin->host;
  414. $parsed->flags['host-only'] = true;
  415. } else {
  416. $parsed->flags['host-only'] = false;
  417. }
  418. $path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/');
  419. if (!$path_is_valid && !empty($origin)) {
  420. $path = $origin->path;
  421. // Default path normalization as per RFC 6265 section 5.1.4
  422. if (substr($path, 0, 1) !== '/') {
  423. // If the uri-path is empty or if the first character of
  424. // the uri-path is not a %x2F ("/") character, output
  425. // %x2F ("/") and skip the remaining steps.
  426. $path = '/';
  427. } elseif (substr_count($path, '/') === 1) {
  428. // If the uri-path contains no more than one %x2F ("/")
  429. // character, output %x2F ("/") and skip the remaining
  430. // step.
  431. $path = '/';
  432. } else {
  433. // Output the characters of the uri-path from the first
  434. // character up to, but not including, the right-most
  435. // %x2F ("/").
  436. $path = substr($path, 0, strrpos($path, '/'));
  437. }
  438. $parsed->attributes['path'] = $path;
  439. }
  440. // Reject invalid cookie domains
  441. if (!empty($origin) && !$parsed->domain_matches($origin->host)) {
  442. continue;
  443. }
  444. $cookies[$parsed->name] = $parsed;
  445. }
  446. return $cookies;
  447. }
  448. }