├── .editorconfig ├── .gitignore ├── LICENSE ├── Migration.md ├── README.md ├── composer.json ├── composer.lock ├── src ├── Cookie.php └── Session.php └── tests └── index.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | indent_style = space 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | 4 | # Composer 5 | vendor/ 6 | composer.phar 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) delight.im (https://www.delight.im/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Migration.md: -------------------------------------------------------------------------------- 1 | # Migration 2 | 3 | ## From `v1.x.x` to `v2.x.x` 4 | 5 | * The license has been changed from the [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) to the [MIT License](https://opensource.org/licenses/MIT). 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP-Cookie 2 | 3 | This is a PHP7-only fork of [Delight IM's Cookie library](https://github.com/delight-im/PHP-Cookie) 4 | which uses the maximum level of security by default. 5 | 6 | This means: 7 | 8 | * Secure is set to `TRUE` unless you override it. 9 | * HTTP-Only is set to `TRUE` unless you override it. 10 | * Same-Site is set to `Strict` unless you override it. 11 | 12 | ---- 13 | 14 | Modern cookie management for PHP 15 | 16 | ## Requirements 17 | 18 | * PHP 7+ 19 | 20 | ## Installation 21 | 22 | * Install via [Composer](https://getcomposer.org/) (recommended) 23 | 24 | `$ composer require paragonie/cookie` 25 | 26 | Include the Composer autoloader: 27 | 28 | `require __DIR__.'/vendor/autoload.php';` 29 | 30 | * or 31 | 32 | * Install manually 33 | 34 | * Copy the contents of the [`src`](src) directory to a subfolder of your project 35 | * Include the files in your code via `require` or `require_once` 36 | 37 | ## Usage 38 | 39 | ### Static method 40 | 41 | This library provides a static method that is compatible to PHP's built-in `setcookie(...)` function but includes support for more recent features such as the [`SameSite`](https://tools.ietf.org/html/draft-west-first-party-cookies-07) attribute: 42 | 43 | ```php 44 | \ParagonIE\Cookie\Cookie::setcookie('SID', '31d4d96e407aad42'); 45 | // or 46 | \ParagonIE\Cookie\Cookie::setcookie('SID', '31d4d96e407aad42', time() + 3600, '/~rasmus/', 'example.com', true, true, 'Lax'); 47 | ``` 48 | 49 | ### Builder pattern 50 | 51 | Instances of the `Cookie` class let you build a cookie conveniently by setting individual properties. This class uses reasonable defaults that may differ from defaults of the `setcookie` function. 52 | 53 | ```php 54 | $cookie = new \ParagonIE\Cookie\Cookie('SID'); 55 | $cookie->setValue('31d4d96e407aad42'); 56 | $cookie->setMaxAge(60 * 60 * 24); 57 | // $cookie->setExpiryTime(time() + 60 * 60 * 24); 58 | $cookie->setPath('/~rasmus/'); 59 | $cookie->setDomain('example.com'); 60 | $cookie->setHttpOnly(true); 61 | $cookie->setSecureOnly(true); 62 | $cookie->setSameSiteRestriction('Strict'); 63 | // echo $cookie; 64 | $cookie->save(); 65 | ``` 66 | 67 | The method calls can also be chained: 68 | 69 | ```php 70 | (new \ParagonIE\Cookie\Cookie('SID'))->setValue('31d4d96e407aad42')->setMaxAge(60 * 60 * 24)->setSameSiteRestriction('Strict')->save(); 71 | ``` 72 | 73 | A cookie can later be deleted simply like this: 74 | 75 | ```php 76 | $cookie->delete(); 77 | ``` 78 | 79 | **Note:** For the deletion to work, the cookie must have the same settings as the cookie that was originally saved. So you should remember to pass appropriate values to `setPath(...)`, `setDomain(...)`, `setHttpOnly(...)` and `setSecureOnly(...)` again. 80 | 81 | ### Managing sessions 82 | 83 | Using the `Session` class, you can start and resume sessions in a way that is compatible to PHP's built-in `session_start()` function, while having access to the improved cookie handling from this library as well: 84 | 85 | ```php 86 | // start session and have session cookie with 'lax' same-site restriction 87 | \ParagonIE\Cookie\Session::start(); 88 | // or 89 | \ParagonIE\Cookie\Session::start('Lax'); 90 | 91 | // start session and have session cookie with 'strict' same-site restriction 92 | \ParagonIE\Cookie\Session::start('Strict'); 93 | 94 | // start session and have session cookie without any same-site restriction 95 | \ParagonIE\Cookie\Session::start(null); 96 | ``` 97 | 98 | All three calls respect the settings from PHP's `session_set_cookie_params(...)` function and the configuration options `session.name`, `session.cookie_lifetime`, `session.cookie_path`, `session.cookie_domain`, `session.cookie_secure`, `session.cookie_httponly` and `session.use_cookies`. 99 | 100 | Likewise, replacements for 101 | 102 | ```php 103 | session_regenerate_id(); 104 | // and 105 | session_regenerate_id(true); 106 | ``` 107 | 108 | are available via 109 | 110 | ```php 111 | \ParagonIE\Cookie\Session::regenerate(); 112 | // and 113 | \ParagonIE\Cookie\Session::regenerate(true); 114 | ``` 115 | 116 | if you want protection against session fixation attacks that comes with improved cookie handling. 117 | 118 | Additionally, access to the current internal session ID is provided via 119 | 120 | ```php 121 | \ParagonIE\Cookie\Session::id(); 122 | ``` 123 | 124 | as a replacement for 125 | 126 | ```php 127 | session_id(); 128 | ``` 129 | 130 | ### Reading and writing session data 131 | 132 | * Read a value from the session (with optional default value): 133 | 134 | ```php 135 | $value = \ParagonIE\Cookie\Session::get($key); 136 | // or 137 | $value = \ParagonIE\Cookie\Session::get($key, $defaultValue); 138 | ``` 139 | 140 | * Write a value to the session: 141 | 142 | ```php 143 | \ParagonIE\Cookie\Session::set($key, $value); 144 | ``` 145 | 146 | * Check whether a value exists in the session: 147 | 148 | ```php 149 | if (\ParagonIE\Cookie\Session::has($key)) { 150 | // ... 151 | } 152 | ``` 153 | 154 | * Remove a value from the session: 155 | 156 | ```php 157 | \ParagonIE\Cookie\Session::delete($key); 158 | ``` 159 | 160 | * Read *and then* immediately remove a value from the session: 161 | 162 | ```php 163 | $value = \ParagonIE\Cookie\Session::take($key); 164 | $value = \ParagonIE\Cookie\Session::take($key, $defaultValue); 165 | ``` 166 | 167 | This is often useful for flash messages, e.g. in combination with the `has(...)` method. 168 | 169 | ### Parsing cookies 170 | 171 | ```php 172 | $cookieHeader = 'Set-Cookie: test=php.net; expires=Thu, 09-Jun-2016 16:30:32 GMT; Max-Age=3600; path=/~rasmus/; secure'; 173 | $cookieInstance = \ParagonIE\Cookie\Cookie::parse($cookieHeader); 174 | ``` 175 | 176 | ## Specifications 177 | 178 | * [RFC 2109](https://tools.ietf.org/html/rfc2109) 179 | * [RFC 6265](https://tools.ietf.org/html/rfc6265) 180 | * [Same-site Cookies](https://tools.ietf.org/html/draft-west-first-party-cookies-07) 181 | 182 | ## Contributing 183 | 184 | All contributions are welcome! If you wish to contribute, please create an issue first so that your feature, problem or question can be discussed. 185 | 186 | ## License 187 | 188 | This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT). 189 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/cookie", 3 | "description": "Modern cookie management for PHP 7", 4 | "require": { 5 | "php": "^7", 6 | "delight-im/http": "^2.0" 7 | }, 8 | "type": "library", 9 | "keywords": [ "cookie", "cookies", "http", "csrf", "xss", "samesite", "same-site" ], 10 | "homepage": "https://github.com/paragonie/PHP-Cookie", 11 | "license": "MIT", 12 | "autoload": { 13 | "psr-4": { 14 | "ParagonIE\\Cookie\\": "src/" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "hash": "53328bf5f9f456c2cd1108f6749632c2", 8 | "content-hash": "b8ee3b75ad43c5b79f71f391211a1940", 9 | "packages": [ 10 | { 11 | "name": "delight-im/http", 12 | "version": "v2.0.0", 13 | "source": { 14 | "type": "git", 15 | "url": "https://github.com/delight-im/PHP-HTTP.git", 16 | "reference": "0a19a72a7eac8b1301aa972fb20cff494ac43e09" 17 | }, 18 | "dist": { 19 | "type": "zip", 20 | "url": "https://api.github.com/repos/delight-im/PHP-HTTP/zipball/0a19a72a7eac8b1301aa972fb20cff494ac43e09", 21 | "reference": "0a19a72a7eac8b1301aa972fb20cff494ac43e09", 22 | "shasum": "" 23 | }, 24 | "require": { 25 | "php": ">=5.3.0" 26 | }, 27 | "type": "library", 28 | "autoload": { 29 | "psr-4": { 30 | "Delight\\Http\\": "src/" 31 | } 32 | }, 33 | "notification-url": "https://packagist.org/downloads/", 34 | "license": [ 35 | "MIT" 36 | ], 37 | "description": "Hypertext Transfer Protocol (HTTP) utilities for PHP", 38 | "homepage": "https://github.com/delight-im/PHP-HTTP", 39 | "keywords": [ 40 | "headers", 41 | "http", 42 | "https" 43 | ], 44 | "time": "2016-07-21 15:05:01" 45 | } 46 | ], 47 | "packages-dev": [], 48 | "aliases": [], 49 | "minimum-stability": "stable", 50 | "stability-flags": [], 51 | "prefer-stable": false, 52 | "prefer-lowest": false, 53 | "platform": { 54 | "php": "^7" 55 | }, 56 | "platform-dev": [] 57 | } 58 | -------------------------------------------------------------------------------- /src/Cookie.php: -------------------------------------------------------------------------------- 1 | name = $name; 95 | $this->value = null; 96 | $this->expiryTime = 0; 97 | $this->path = '/'; 98 | $this->setDomain(self::normalizeDomain($domain ?? $_SERVER['HTTP_HOST'])); 99 | $this->httpOnly = true; 100 | $this->secureOnly = false; 101 | $this->sameSiteRestriction = self::SAME_SITE_RESTRICTION_STRICT; 102 | } 103 | 104 | /** 105 | * Sets the value for the cookie 106 | * 107 | * @param mixed $value The value of the cookie that will be stored on the 108 | * client's machine. 109 | * @return self This instance for chaining 110 | */ 111 | public function setValue($value): self 112 | { 113 | $this->value = $value; 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * Sets the expiry time for the cookie 120 | * 121 | * @param int $expiryTime The Unix timestamp indicating the time that the 122 | * cookie will expire, i.e. usually 123 | * `time() + $seconds`. 124 | * @return self This instance for chaining 125 | */ 126 | public function setExpiryTime(int $expiryTime) 127 | { 128 | $this->expiryTime = $expiryTime; 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Sets the expiry time for the cookie based on the specified maximum age 135 | * 136 | * @param int $maxAge The maximum age for the cookie in seconds. 137 | * @return self This instance for chaining 138 | */ 139 | public function setMaxAge(int $maxAge) 140 | { 141 | $this->expiryTime = time() + $maxAge; 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Sets the path for the cookie 148 | * 149 | * @param string $path The path on the server that the cookie will be valid 150 | * for (including all sub-directories), e.g. an empty 151 | * string for the current directory or `/` for the root 152 | * directory. 153 | * @return self This instance for chaining 154 | */ 155 | public function setPath(string $path): self 156 | { 157 | $this->path = $path; 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * Sets the domain for the cookie 164 | * 165 | * @param string $domain The domain that the cookie will be valid for (including all subdomains) 166 | * @param bool $keepWww whether a leading `www` subdomain must be preserved or not 167 | * @return self This instance for chaining 168 | */ 169 | public function setDomain(string $domain, bool $keepWww = false): self 170 | { 171 | $this->domain = self::normalizeDomain($domain, $keepWww); 172 | 173 | return $this; 174 | } 175 | 176 | /** 177 | * Sets whether the cookie should be accessible through HTTP only 178 | * 179 | * @param bool $httpOnly Indicates that the cookie should be accessible 180 | * through the HTTP protocol only and not through 181 | * scripting languages. 182 | * @return self This instance for chaining 183 | */ 184 | public function setHttpOnly(bool $httpOnly): self 185 | { 186 | $this->httpOnly = $httpOnly; 187 | 188 | return $this; 189 | } 190 | 191 | /** 192 | * Sets whether the cookie should be sent over HTTPS only 193 | * 194 | * @param bool $secureOnly Indicates that the cookie should be sent back by 195 | * the client over secure HTTPS connections only. 196 | * @return self This instance for chaining 197 | */ 198 | public function setSecureOnly(bool $secureOnly): self 199 | { 200 | $this->secureOnly = $secureOnly; 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Sets the same-site restriction for the cookie 207 | * 208 | * @param string $sameSiteRestriction Indicates that the cookie should not 209 | * be sent along with cross-site 210 | * requests (either `Lax`, `Strict`, or 211 | * an empty string). 212 | * @return self This instance for chaining 213 | */ 214 | public function setSameSiteRestriction(string $sameSiteRestriction): self 215 | { 216 | $this->sameSiteRestriction = $sameSiteRestriction; 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Saves the cookie 223 | * 224 | * @return bool Whether the cookie header has successfully been sent (and 225 | * will *probably* cause the client to set the cookie) 226 | */ 227 | public function save(): bool 228 | { 229 | return self::addHttpHeader((string) $this); 230 | } 231 | 232 | /** 233 | * Deletes the cookie 234 | * 235 | * @return bool Whether the cookie header has successfully been sent (and 236 | * will *probably* cause the client to delete the cookie) 237 | */ 238 | public function delete(): bool 239 | { 240 | // create a temporary copy of this cookie so that it isn't corrupted 241 | $copiedCookie = clone $this; 242 | 243 | // set the copied cookie's value to an empty string which internally 244 | // sets the required options for a deletion 245 | $copiedCookie->setValue(''); 246 | 247 | // save the copied "deletion" cookie 248 | return $copiedCookie->save(); 249 | } 250 | 251 | /** 252 | * @return string 253 | */ 254 | public function __toString() 255 | { 256 | return self::buildCookieHeader( 257 | $this->name, 258 | $this->value, 259 | $this->expiryTime, 260 | $this->path, 261 | $this->domain, 262 | $this->secureOnly, 263 | $this->httpOnly, 264 | $this->sameSiteRestriction 265 | ); 266 | } 267 | 268 | /** 269 | * Sets a new cookie in a way compatible to PHP's `setcookie(...)` function 270 | * 271 | * @param string $name The name of the cookie which is also 272 | * the key for future accesses via 273 | * `$_COOKIE[...]`. 274 | * @param mixed $value The value of the cookie that will be 275 | * stored on the client's machine. 276 | * @param int $expiryTime The Unix timestamp indicating the 277 | * time that the cookie will expire, 278 | * i.e. usually `time() + $seconds`. 279 | * @param string $path The path on the server that the cookie 280 | * will be valid for (including all sub- 281 | * directories), e.g. an empty string for 282 | * the current directory or `/` for the 283 | * root directory. 284 | * @param string $domain The domain that the cookie will be 285 | * valid for (including all subdomains). 286 | * @param bool $secureOnly Indicates that the cookie should be 287 | * sent back by the client over secure 288 | * HTTPS connections only. 289 | * @param bool $httpOnly Indicates that the cookie should be 290 | * accessible through the HTTP protocol 291 | * only and not through scripting 292 | * languages. 293 | * @param string $sameSiteRestriction Indicates that the cookie should not 294 | * be sent along with cross-site 295 | * requests (either `Lax`, `Strict`, or 296 | * an empty string). 297 | * @return bool Whether the cookie header has successfully 298 | * been sent (and will *probably* cause 299 | * the client to set the cookie). 300 | */ 301 | public static function setcookie( 302 | string $name, 303 | $value = null, 304 | int $expiryTime = 0, 305 | string $path = '', 306 | string $domain = '', 307 | bool $secureOnly = true, 308 | bool $httpOnly = true, 309 | string $sameSiteRestriction = self::SAME_SITE_RESTRICTION_STRICT 310 | ): bool { 311 | $cookieHeader = self::buildCookieHeader( 312 | $name, 313 | $value, 314 | $expiryTime, 315 | $path, 316 | $domain, 317 | $secureOnly, 318 | $httpOnly, 319 | $sameSiteRestriction 320 | ); 321 | 322 | return self::addHttpHeader($cookieHeader); 323 | } 324 | 325 | /** 326 | * Builds the HTTP header that can be used to set a cookie with the 327 | * specified options. 328 | * 329 | * @param string $name The name of the cookie which is also 330 | * the key for future accesses via 331 | * `$_COOKIE[...]`. 332 | * @param mixed $value The value of the cookie that will be 333 | * stored on the client's machine. 334 | * @param int $expiryTime The Unix timestamp indicating the 335 | * time that the cookie will expire, 336 | * i.e. usually `time() + $seconds`. 337 | * @param string $path The path on the server that the cookie 338 | * will be valid for (including all sub- 339 | * directories), e.g. an empty string for 340 | * the current directory or `/` for the 341 | * root directory. 342 | * @param string $domain The domain that the cookie will be 343 | * valid for (including all subdomains). 344 | * @param bool $secureOnly Indicates that the cookie should be 345 | * sent back by the client over secure 346 | * HTTPS connections only. 347 | * @param bool $httpOnly Indicates that the cookie should be 348 | * accessible through the HTTP protocol 349 | * only and not through scripting 350 | * languages. 351 | * @param string $sameSiteRestriction Indicates that the cookie should not 352 | * be sent along with cross-site 353 | * requests (either `Lax`, `Strict`, or 354 | * an empty string). 355 | * @return string the HTTP header 356 | * @throws \Exception 357 | */ 358 | public static function buildCookieHeader( 359 | string $name, 360 | $value = null, 361 | int $expiryTime = 0, 362 | string $path = '', 363 | string $domain = '', 364 | bool $secureOnly = true, 365 | bool $httpOnly = true, 366 | string $sameSiteRestriction = self::SAME_SITE_RESTRICTION_STRICT 367 | ): string { 368 | if (!self::isNameValid($name)) { 369 | throw new \Exception('Invalid cookie name'); 370 | } 371 | 372 | if (!self::isExpiryTimeValid($expiryTime)) { 373 | throw new \Exception('Invalid expiration time'); 374 | } 375 | 376 | $forceShowExpiry = false; 377 | 378 | if (empty($value)) { 379 | $value = 'deleted'; 380 | $expiryTime = 0; 381 | $forceShowExpiry = true; 382 | } 383 | 384 | $maxAgeStr = self::formatMaxAge( 385 | $expiryTime, 386 | $forceShowExpiry 387 | ); 388 | $expiryTimeStr = self::formatExpiryTime( 389 | $expiryTime, 390 | $forceShowExpiry 391 | ); 392 | 393 | $headerStr = 'Set-Cookie: ' . $name . '=' . urlencode($value); 394 | 395 | if (!empty($expiryTimeStr)) { 396 | $headerStr .= '; expires=' . $expiryTimeStr; 397 | } 398 | 399 | if (!empty($maxAgeStr)) { 400 | $headerStr .= '; Max-Age=' . $maxAgeStr; 401 | } 402 | 403 | if (!empty($path) || $path === 0) { 404 | $headerStr .= '; path=' . $path; 405 | } 406 | 407 | if (!empty($domain) || $domain === 0) { 408 | $headerStr .= '; domain=' . $domain; 409 | } 410 | 411 | if ($secureOnly) { 412 | $headerStr .= '; secure'; 413 | } 414 | 415 | if ($httpOnly) { 416 | $headerStr .= '; httponly'; 417 | } 418 | 419 | if ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_LAX) { 420 | $headerStr .= '; SameSite=Lax'; 421 | } 422 | elseif ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_STRICT) { 423 | $headerStr .= '; SameSite=Strict'; 424 | } 425 | 426 | return $headerStr; 427 | } 428 | 429 | /** 430 | * Parses the given cookie header and returns an equivalent cookie instance 431 | * 432 | * @param string $cookieHeader the cookie header to parse 433 | * @return self 434 | * @throws \Exception 435 | */ 436 | public static function parse(string $cookieHeader): self 437 | { 438 | if (empty($cookieHeader)) { 439 | throw new \Exception('Not a valid Set-Cookie header.'); 440 | } 441 | 442 | if (\preg_match('/^Set-Cookie: (.*?)=(.*?)(?:; (.*?))?$/i', $cookieHeader, $matches)) { 443 | if (\count($matches) >= 4) { 444 | $attributes = \explode('; ', $matches[3]); 445 | 446 | $cookie = new self($matches[1]); 447 | $cookie->setPath(''); 448 | $cookie->setHttpOnly(false); 449 | $cookie->setValue($matches[2]); 450 | 451 | foreach ($attributes as $attribute) { 452 | if (\strcasecmp($attribute, 'HttpOnly') === 0) { 453 | $cookie->setHttpOnly(true); 454 | } elseif (\strcasecmp($attribute, 'Secure') === 0) { 455 | $cookie->setSecureOnly(true); 456 | } elseif (\stripos($attribute, 'Expires=') === 0) { 457 | $cookie->setExpiryTime((int) strtotime(substr($attribute, 8))); 458 | } elseif (\stripos($attribute, 'Domain=') === 0) { 459 | $cookie->setDomain(substr($attribute, 7), true); 460 | } elseif (\stripos($attribute, 'Path=') === 0) { 461 | $cookie->setPath(substr($attribute, 5)); 462 | } 463 | } 464 | return $cookie; 465 | } 466 | } 467 | throw new \Exception('Not a valid Set-Cookie header.'); 468 | } 469 | 470 | /** 471 | * Is a cookie name valid? 472 | * 473 | * @param $name 474 | * @return bool 475 | */ 476 | private static function isNameValid(string $name): bool 477 | { 478 | if ($name !== '') { 479 | if (!\preg_match('/[=,; \\t\\r\\n\\013\\014]/', $name)) { 480 | return true; 481 | } 482 | } 483 | 484 | return false; 485 | } 486 | 487 | /** 488 | * @param $expiryTime 489 | * @return bool 490 | */ 491 | private static function isExpiryTimeValid($expiryTime): bool 492 | { 493 | return \is_numeric($expiryTime); 494 | } 495 | 496 | /** 497 | * @param $expiryTime 498 | * @return int 499 | */ 500 | private static function calculateMaxAge(int $expiryTime): int 501 | { 502 | if ($expiryTime === 0) { 503 | return 0; 504 | } 505 | return $expiryTime - time(); 506 | } 507 | 508 | /** 509 | * Format expiry time. 510 | * 511 | * @param $expiryTime 512 | * @param bool $forceShow 513 | * @return string 514 | */ 515 | private static function formatExpiryTime( 516 | int $expiryTime, 517 | bool $forceShow = false 518 | ): string { 519 | if ($expiryTime > 0 || $forceShow) { 520 | if ($forceShow) { 521 | $expiryTime = 1; 522 | } 523 | 524 | $date = \gmdate('D, d-M-Y H:i:s T', $expiryTime); 525 | if ($date !== false) { 526 | return $date; 527 | } 528 | } 529 | return ''; 530 | } 531 | 532 | /** 533 | * Format maximum cookie age. 534 | * 535 | * @param int $expiryTime 536 | * @param bool $forceShow 537 | * @return string 538 | */ 539 | private static function formatMaxAge( 540 | int $expiryTime, 541 | bool $forceShow = false 542 | ): string { 543 | if ($expiryTime > 0 || $forceShow) { 544 | return (string) self::calculateMaxAge($expiryTime); 545 | } 546 | return ''; 547 | } 548 | 549 | /** 550 | * Normalize a domain name. 551 | * 552 | * @param string $domain 553 | * @param bool $keepWww 554 | * @return string 555 | */ 556 | private static function normalizeDomain( 557 | string $domain = '', 558 | bool $keepWww = false 559 | ): string { 560 | // if the cookie should be valid for the current host only 561 | if ($domain === '') { 562 | // no need for further normalization 563 | return ''; 564 | } 565 | 566 | // if the provided domain is actually an IP address 567 | if (\filter_var($domain, FILTER_VALIDATE_IP) !== false) { 568 | // let the cookie be valid for the current host 569 | return ''; 570 | } 571 | 572 | // for local hostnames (which either have no dot at all or a leading dot only) 573 | if (\strpos($domain, '.') === false || \strrpos($domain, '.') === 0) { 574 | // let the cookie be valid for the current host while ensuring 575 | // maximum compatibility 576 | return ''; 577 | } 578 | 579 | // unless the domain already starts with a dot 580 | if ($domain[0] !== '.') { 581 | // prepend a dot for maximum compatibility (e.g. with RFC 2109) 582 | $domain = '.' . $domain; 583 | } 584 | 585 | // if a leading `www` subdomain may be dropped 586 | if (!$keepWww) { 587 | // if the domain name actually starts with a `www` subdomain 588 | if (\substr($domain, 0, 5) === '.www.') { 589 | // strip that subdomain 590 | $domain = \substr($domain, 4); 591 | } 592 | } 593 | 594 | // return the normalized domain 595 | return $domain; 596 | } 597 | 598 | /** 599 | * Send an additional HTTP header. 600 | * 601 | * @param $header 602 | * @return bool 603 | */ 604 | private static function addHttpHeader(string $header): bool 605 | { 606 | if (!\headers_sent()) { 607 | if (!empty($header)) { 608 | \header($header, false); 609 | 610 | return true; 611 | } 612 | } 613 | 614 | return false; 615 | } 616 | } 617 | -------------------------------------------------------------------------------- /src/Session.php: -------------------------------------------------------------------------------- 1 | setSameSiteRestriction($sameSiteRestriction); 182 | // save the cookie 183 | $parsedCookie->save(); 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /tests/index.php: -------------------------------------------------------------------------------- 1 | setValue('31d4d96e407aad42')->setDomain('localhost')->setSameSiteRestriction('Strict'), 'Set-Cookie: SID=31d4d96e407aad42; path=/; httponly; SameSite=Strict'); 105 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('localhost'), 'Set-Cookie: key=value; path=/; httponly; SameSite=Lax'); 106 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.localhost'), 'Set-Cookie: key=value; path=/; httponly; SameSite=Lax'); 107 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('127.0.0.1'), 'Set-Cookie: key=value; path=/; httponly; SameSite=Lax'); 108 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.local'), 'Set-Cookie: key=value; path=/; httponly; SameSite=Lax'); 109 | testEqual((new \ParagonIE\Cookie\Cookie('key', 'example.com'))->setValue('value'), 'Set-Cookie: key=value; path=/; domain=.example.com; httponly; SameSite=Lax'); 110 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.example.com'), 'Set-Cookie: key=value; path=/; domain=.example.com; httponly; SameSite=Lax'); 111 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('www.example.com'), 'Set-Cookie: key=value; path=/; domain=.example.com; httponly; SameSite=Lax'); 112 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.www.example.com'), 'Set-Cookie: key=value; path=/; domain=.example.com; httponly; SameSite=Lax'); 113 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('www.example.com', true), 'Set-Cookie: key=value; path=/; domain=.www.example.com; httponly; SameSite=Lax'); 114 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.www.example.com', true), 'Set-Cookie: key=value; path=/; domain=.www.example.com; httponly; SameSite=Lax'); 115 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('blog.example.com'), 'Set-Cookie: key=value; path=/; domain=.blog.example.com; httponly; SameSite=Lax'); 116 | testEqual((new \ParagonIE\Cookie\Cookie('key'))->setValue('value')->setDomain('.blog.example.com'), 'Set-Cookie: key=value; path=/; domain=.blog.example.com; httponly; SameSite=Lax'); 117 | 118 | setcookie('hello', 'world', time() + 86400, '/foo/', 'example.com', true, true); 119 | testEqual(\ParagonIE\Cookie\Cookie::parse(\Delight\Http\ResponseHeader::take('Set-Cookie')), (new \ParagonIE\Cookie\Cookie('hello'))->setValue('world')->setMaxAge(86400)->setPath('/foo/')->setDomain('example.com')->setHttpOnly(true)->setSecureOnly(true)); 120 | 121 | /* END TEST COOKIES */ 122 | 123 | /* BEGIN TEST SESSION */ 124 | 125 | // enable assertions 126 | ini_set('assert.active', 1); 127 | ini_set('zend.assertions', 1); 128 | ini_set('assert.exception', 1); 129 | 130 | assert(isset($_SESSION) === false); 131 | assert(\ParagonIE\Cookie\Session::id() === ''); 132 | 133 | \ParagonIE\Cookie\Session::start(); 134 | 135 | assert(isset($_SESSION) === true); 136 | assert(\ParagonIE\Cookie\Session::id() !== ''); 137 | 138 | $oldSessionId = \ParagonIE\Cookie\Session::id(); 139 | \ParagonIE\Cookie\Session::regenerate(); 140 | assert(\ParagonIE\Cookie\Session::id() !== $oldSessionId); 141 | assert(\ParagonIE\Cookie\Session::id() !== null); 142 | 143 | session_unset(); 144 | 145 | assert(isset($_SESSION['key1']) === false); 146 | assert(\ParagonIE\Cookie\Session::has('key1') === false); 147 | assert(\ParagonIE\Cookie\Session::get('key1') === null); 148 | assert(\ParagonIE\Cookie\Session::get('key1', 5) === 5); 149 | assert(\ParagonIE\Cookie\Session::get('key1', 'monkey') === 'monkey'); 150 | 151 | \ParagonIE\Cookie\Session::set('key1', 'value1'); 152 | 153 | assert(isset($_SESSION['key1']) === true); 154 | assert(\ParagonIE\Cookie\Session::has('key1') === true); 155 | assert(\ParagonIE\Cookie\Session::get('key1') === 'value1'); 156 | assert(\ParagonIE\Cookie\Session::get('key1', 5) === 'value1'); 157 | assert(\ParagonIE\Cookie\Session::get('key1', 'monkey') === 'value1'); 158 | 159 | assert(\ParagonIE\Cookie\Session::take('key1') === 'value1'); 160 | assert(\ParagonIE\Cookie\Session::take('key1') === null); 161 | assert(\ParagonIE\Cookie\Session::take('key1', 'value2') === 'value2'); 162 | assert(isset($_SESSION['key1']) === false); 163 | assert(\ParagonIE\Cookie\Session::has('key1') === false); 164 | 165 | \ParagonIE\Cookie\Session::set('key2', 'value3'); 166 | 167 | assert(isset($_SESSION['key2']) === true); 168 | assert(\ParagonIE\Cookie\Session::has('key2') === true); 169 | assert(\ParagonIE\Cookie\Session::get('key2', 'value4') === 'value3'); 170 | \ParagonIE\Cookie\Session::delete('key2'); 171 | assert(\ParagonIE\Cookie\Session::get('key2', 'value4') === 'value4'); 172 | assert(\ParagonIE\Cookie\Session::get('key2') === null); 173 | assert(\ParagonIE\Cookie\Session::has('key2') === false); 174 | 175 | session_destroy(); 176 | 177 | /* END TEST SESSION */ 178 | 179 | echo 'ALL TESTS PASSED'."\n"; 180 | 181 | function testCookie($name, $value = null, $expire = 0, $path = null, $domain = null, $secure = false, $httpOnly = false) { 182 | $actualValue = \ParagonIE\Cookie\Cookie::buildCookieHeader($name, $value, $expire, $path, $domain, $secure, $httpOnly); 183 | if (is_null($actualValue)) { 184 | $expectedValue = @simulateSetCookie($name, $value, $expire, $path, $domain, $secure, $httpOnly); 185 | } 186 | else { 187 | $expectedValue = simulateSetCookie($name, $value, $expire, $path, $domain, $secure, $httpOnly); 188 | } 189 | 190 | testEqual($actualValue, $expectedValue); 191 | } 192 | 193 | function testEqual($actualValue, $expectedValue) { 194 | $actualValue = (string) $actualValue; 195 | $expectedValue = (string) $expectedValue; 196 | 197 | echo '['; 198 | echo $expectedValue; 199 | echo ']'; 200 | echo "\n"; 201 | 202 | if (strcasecmp($actualValue, $expectedValue) !== 0) { 203 | echo 'FAILED: '; 204 | echo '['; 205 | echo $actualValue; 206 | echo ']'; 207 | echo ' !== '; 208 | echo '['; 209 | echo $expectedValue; 210 | echo ']'; 211 | echo "\n"; 212 | 213 | exit; 214 | } 215 | } 216 | 217 | function simulateSetCookie($name, $value = null, $expire = 0, $path = null, $domain = null, $secure = false, $httpOnly = false) { 218 | setcookie($name, $value, $expire, $path, $domain, $secure, $httpOnly); 219 | 220 | return \Delight\Http\ResponseHeader::take('Set-Cookie'); 221 | } 222 | --------------------------------------------------------------------------------