From d29ed3eb96831f6b3fd56ac0f19fbcc6f2b61117 Mon Sep 17 00:00:00 2001 From: pine3ree Date: Mon, 10 May 2021 14:46:37 -0400 Subject: [PATCH] Add PR #187 - add cookie SameSite support and settings (session and other cookies) --- wire/config.php | 21 +++++ wire/core/Config.php | 1 + wire/core/Session.php | 92 +++++++++++++++++-- wire/core/WireInput.php | 2 +- wire/core/WireInputDataCookie.php | 32 ++++++- .../SessionHandlerDB/SessionHandlerDB.module | 19 +++- 6 files changed, 154 insertions(+), 13 deletions(-) diff --git a/wire/config.php b/wire/config.php index 1ca24e0e..db905542 100644 --- a/wire/config.php +++ b/wire/config.php @@ -420,6 +420,25 @@ $config->sessionCookieSecure = 1; */ $config->sessionCookieDomain = null; +/** + * Cookie “SameSite” value for sessions - “Lax” (default) or “Strict” + * + * - `Lax`: The session cookie will be sent along with the GET requests initiated by third party website. + * This ensures an existing session on this site is maintained when clicking to it from another site. + * + * - `Strict`: The session cookie will not be sent along with requests initiated by third party websites. + * If user already has a login session on this site, it won’t be recognized when clicking from another + * site to this one. + * + * The default/recommended value is `Lax`. + * + * @var string + * @since 3.0.178 + * @see https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-samesite + * + */ +$config->sessionCookieSameSite = 'Lax'; + /** * Number of session history entries to record. * @@ -1020,6 +1039,7 @@ $config->wireInputLazy = false; * #property int age Max age of cookies in seconds or 0 to expire with session (3600=1hr, 86400=1day, 604800=1week, 2592000=30days, etc.) * #property string|null Cookie path or null for PW installation’s root URL (default=null). * #property string|null|bool domain Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. + * #property string samesite When set to “Lax” cookies are preserved on GET requests to this site originated from external links. May also be 'Strict' or 'None' ('secure' option required for 'None'). 3.0.178+ * #property bool|null secure Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, using true option for cookies set when HTTPS is active). * #property bool httponly When true, cookie is http/server-side and not visible to JS code in most browsers. * @@ -1032,6 +1052,7 @@ $config->cookieOptions = array( 'path' => null, // Cookie path/URL or null for PW installation’s root URL (default=null). 'domain' => null, // Cookie domain: null for current hostname, true for all subdomains of current domain, domain.com for domain and all subdomains, www.domain.com for www subdomain. 'secure' => null, // Transmit cookies only over secure HTTPS connection? (true, false, or null to auto-detect, substituting true for cookies set when HTTPS is active). + 'samesite' => 'Lax', // When set to “Lax” cookies are preserved on GET requests to this site originated from external links. May also be 'Strict' or 'None' ('secure' option required for 'None'). 'httponly' => false, // When true, cookie is http/server-side only and not visible to client-side JS code. 'fallback' => true, // If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true) ); diff --git a/wire/core/Config.php b/wire/core/Config.php index d74724b6..825bfd78 100644 --- a/wire/core/Config.php +++ b/wire/core/Config.php @@ -62,6 +62,7 @@ * @property string $sessionNameSecure Session name when on HTTPS. Used when the sessionCookieSecure option is enabled (default). When blank (default), it will assume sessionName + 's'. #pw-group-session * @property bool|int $sessionCookieSecure Use secure cookies when on HTTPS? When enabled, separate sessions will be maintained for HTTP vs. HTTPS. Good for security but tradeoff is login session may be lost when switching (default=1 or true). #pw-group-session * @property null|string $sessionCookieDomain Domain to use for sessions, which enables a session to work across subdomains, or NULL to disable (default/recommended). #pw-group-session + * @property string $sessionCookieSameSite Cookie “SameSite” value for sessions - “Lax” (default) or “Strict”. (3.0.178+) #pw-group-session * @property bool|callable $sessionAllow Are sessions allowed? Typically boolean true, unless provided a callable function that returns boolean. See /wire/config.php for an example. #pw-group-session * @property int $sessionExpireSeconds How many seconds of inactivity before session expires? #pw-group-session * @property bool $sessionChallenge Should login sessions have a challenge key? (for extra security, recommended) #pw-group-session diff --git a/wire/core/Session.php b/wire/core/Session.php index dad20890..5291f012 100644 --- a/wire/core/Session.php +++ b/wire/core/Session.php @@ -305,7 +305,18 @@ class Session extends Wire implements \IteratorAggregate { } } - @session_start(); + $options = array(); + $cookieSameSite = $this->sessionCookieSameSite(); + + if(PHP_VERSION_ID < 70300) { + $cookiePath = ini_get('session.cookie_path'); + if(empty($cookiePath)) $cookiePath = '/'; + $options['cookie_path'] = "$cookiePath; SameSite=$cookieSameSite"; + } else { + $options['cookie_samesite'] = $cookieSameSite; + } + + @session_start($options); if(!empty($this->data)) { foreach($this->data as $key => $value) $this->set($key, $value); @@ -937,8 +948,16 @@ class Session extends Wire implements \IteratorAggregate { $this->set('_user', 'challenge', $challenge); $secure = $this->config->sessionCookieSecure ? (bool) $this->config->https : false; // set challenge cookie to last 30 days (should be longer than any session would feasibly last) - setcookie(session_name() . self::challengeSuffix, $challenge, time()+60*60*24*30, '/', - $this->config->sessionCookieDomain, $secure, true); + $this->setCookie( + session_name() . self::challengeSuffix, + $challenge, + time() + 60*60*24*30, + '/', + $this->config->sessionCookieDomain, + $secure, + true, + $this->config->sessionCookieSameSite + ); } if($this->config->sessionFingerprint) { @@ -947,7 +966,7 @@ class Session extends Wire implements \IteratorAggregate { } $this->wire('user', $user); - $this->get('CSRF')->resetAll(); + $this->CSRF()->resetAll(); $this->loginSuccess($user); $fail = false; @@ -1119,22 +1138,81 @@ class Session extends Wire implements \IteratorAggregate { return $this; } + /** + * Add a SetCookie response header + * + * @param string $name + * @param string|null|false $value + * @param int $expires + * @param string $path + * @param string|null $domain + * @param bool $secure + * @param bool $httponly + * @param string $samesite One of 'Strict', 'Lax', 'None' + * @return bool + * @since 3.0.178 + * + */ + protected function setCookie($name, $value, $expires = 0, $path = '/', $domain = null, $secure = false, $httponly = false, $samesite = 'Lax') { + + if(empty($path)) $path = '/'; + + $samesite = $this->sessionCookieSameSite($samesite); + + if($samesite === 'None') $secure = true; + + if(PHP_VERSION_ID < 70300) { + return setcookie($name, $value, $expires, "$path; SameSite=$samesite", $domain, $secure, $httponly); + } + + // PHP 7.3+ supports $options array + return setcookie($name, $value, array( + 'expires' => $expires, + 'path' => $path, + 'domain' => $domain, + 'secure' => $secure, + 'httponly' => $httponly, + 'samesite' => $samesite, + )); + } + + /** * Remove all cookies used by the session * */ protected function removeCookies() { $sessionName = session_name(); + $challengeName = $sessionName . self::challengeSuffix; $time = time() - 42000; + $domain = $this->config->sessionCookieDomain; $secure = $this->config->sessionCookieSecure ? (bool) $this->config->https : false; + $samesite = $this->sessionCookieSameSite(); + if(isset($_COOKIE[$sessionName])) { - setcookie($sessionName, '', $time, '/', $this->config->sessionCookieDomain, $secure, true); + $this->setCookie($sessionName, '', $time, '/', $domain, $secure, true, $samesite); } - if(isset($_COOKIE[$sessionName . self::challengeSuffix])) { - setcookie($sessionName . self::challengeSuffix, '', $time, '/', $this->config->sessionCookieDomain, $secure, true); + + if(isset($_COOKIE[$challengeName])) { + $this->setCookie($challengeName, '', $time, '/', $domain, $secure, true, $samesite); } } + /** + * Get 'SameSite' value for session cookie + * + * @param string|null $value + * @return string + * @since 3.0.178 + * + */ + protected function sessionCookieSameSite($value = null) { + $samesite = $value === null ? $this->config->sessionCookieSameSite : $value; + $samesite = empty($samesite) ? 'Lax' : ucfirst(strtolower($samesite)); + if(!in_array($samesite, array('Strict', 'Lax', 'None'), true)) $samesite = 'Lax'; + return $samesite; + } + /** * Get the names of all cookies managed by Session * diff --git a/wire/core/WireInput.php b/wire/core/WireInput.php index cd72a932..2f13c26a 100644 --- a/wire/core/WireInput.php +++ b/wire/core/WireInput.php @@ -21,7 +21,7 @@ * @property array|string[] $urlSegments Retrieve all URL segments (array). This requires url segments are enabled on the template of the requested page. You can turn it on or off under the url tab when editing a template. #pw-group-URL-segments * @property WireInputData $post POST variables * @property WireInputData $get GET variables - * @property WireInputData $cookie COOKIE variables + * @property WireInputDataCookie $cookie COOKIE variables * @property WireInputData $whitelist Whitelisted variables * @property int $pageNum Current page number (where 1 is first) #pw-group-URLs * @property string $urlSegmentsStr String of current URL segments, separated by slashes, i.e. a/b/c #pw-internal diff --git a/wire/core/WireInputDataCookie.php b/wire/core/WireInputDataCookie.php index 81d6f8ab..f6aef808 100644 --- a/wire/core/WireInputDataCookie.php +++ b/wire/core/WireInputDataCookie.php @@ -56,6 +56,11 @@ * // Specify true, false, or null to auto-detect (uses true for cookies set when HTTPS). * 'secure' => null, * + * // Cookie SameSite value: When set to “Lax” cookies are preserved on GET requests to this site + * // originated from external links. May also be “Strict” or “None”. The 'secure' option is + * // required for “None”. Default value is “Lax”. Available in PW 3.0.178+. + * 'samesite' => 'Lax', + * * // Make cookies accessible by HTTP (ProcessWire/PHP) only? * // When true, cookie is http/server-side only and not visible to client-side JS code. * 'httponly' => false, @@ -95,6 +100,7 @@ class WireInputDataCookie extends WireInputData { 'domain' => null, 'secure' => null, 'httponly' => false, + 'samesite' => 'Lax', 'fallback' => true, ); @@ -340,6 +346,7 @@ class WireInputDataCookie extends WireInputData { * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. (default=null) * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? Specify true or false, or use null to auto-detect, * which uses true for cookies set when HTTPS is detected. (default=null) + * - `samesite` (string): SameSite value, one of 'Lax' (default), 'Strict' or 'None'. (default='Lax') 3.0.178+ * - `httponly` (bool): When true, cookie is visible to PHP/ProcessWire only and not visible to client-side JS code. (default=false) * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true) * - `domain` (string|bool|null): Cookie domain, specify one of the following: `null` or blank string for current hostname [default], @@ -351,8 +358,7 @@ class WireInputDataCookie extends WireInputData { */ public function setCookie($key, $value, array $options) { - /** @var Config $config */ - $config = $this->wire('config'); + $config = $this->wire()->config; $options = array_merge($this->defaultOptions, $config->cookieOptions, $this->options, $options); $path = $options['path'] === null || $options['path'] === true ? $config->urls->root : $options['path']; @@ -361,6 +367,13 @@ class WireInputDataCookie extends WireInputData { $domain = $options['domain']; $remove = $value === null; $expires = null; + $samesite = $options['samesite'] ? ucfirst(strtolower($options['samesite'])) : 'Lax'; + + if($samesite === 'None') { + $secure = true; + } else if(!in_array($samesite, array('Lax', 'Strict', 'None'), true)) { + $samesite = 'Lax'; + } if(!empty($options['expire'])) { if(is_string($options['expire']) && !ctype_digit($options['expire'])) { @@ -395,11 +408,22 @@ class WireInputDataCookie extends WireInputData { if($remove) list($value, $expires) = array('', 1); // set the cookie - $result = setcookie($key, $value, $expires, $path, $domain, $secure, $httponly); + if(PHP_VERSION_ID < 70300) { + $result = setcookie($key, $value, $expires, "$path; SameSite=$samesite", $domain, $secure, $httponly); + } else { + $result = setcookie($key, $value, array( + 'expires' => $expires, + 'path' => $path, + 'domain' => $domain, + 'secure' => $secure, + 'httponly' => $httponly, + 'samesite' => $samesite, + )); + } if($result === false && $options['fallback']) { // output must have already started, set at construct on next request - $this->wire('session')->setFor($this, $key, $value); + $this->wire()->session->setFor($this, $key, $value); } if($remove) { diff --git a/wire/modules/Session/SessionHandlerDB/SessionHandlerDB.module b/wire/modules/Session/SessionHandlerDB/SessionHandlerDB.module index bf89a3a5..485040fc 100644 --- a/wire/modules/Session/SessionHandlerDB/SessionHandlerDB.module +++ b/wire/modules/Session/SessionHandlerDB/SessionHandlerDB.module @@ -179,7 +179,24 @@ class SessionHandlerDB extends WireSessionHandler implements Module, Configurabl $query = $database->prepare("DELETE FROM `$table` WHERE id=:id"); $query->execute(array(":id" => $id)); $secure = $config->sessionCookieSecure ? (bool) $config->https : false; - setcookie(session_name(), '', time()-42000, '/', $config->sessionCookieDomain, $secure, true); + $expires = time() - 42000; + $samesite = $config->sessionCookieSameSite ? ucfirst(strtolower($config->sessionCookieSameSite)) : 'Lax'; + + if($samesite === 'None') $secure = true; + + if(PHP_VERSION_ID < 70300) { + setcookie(session_name(), '', $expires, "/; SameSite=$samesite", $config->sessionCookieDomain, $secure, true); + } else { + setcookie(session_name(), '', array( + 'expires' => $expires, + 'path' => '/', + 'domain' => $config->sessionCookieDomain, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => $samesite + )); + } + return true; }