From bef5923d667fa9274e76b64a2bdd98662e1d3376 Mon Sep 17 00:00:00 2001 From: Christian Wach Date: Sat, 19 Sep 2020 12:47:26 +0100 Subject: [PATCH] Fix PayPal Standard session loss in WordPress --- CRM/Core/Payment/PayPalImpl.php | 3 + CRM/Utils/SameSite.php | 224 ++++++++++++++++++++++++++++++++ CRM/Utils/System.php | 8 ++ CRM/Utils/System/Base.php | 6 + CRM/Utils/System/WordPress.php | 157 ++++++++++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 CRM/Utils/SameSite.php diff --git a/CRM/Core/Payment/PayPalImpl.php b/CRM/Core/Payment/PayPalImpl.php index b1833a8752..a1002fe925 100644 --- a/CRM/Core/Payment/PayPalImpl.php +++ b/CRM/Core/Payment/PayPalImpl.php @@ -971,6 +971,9 @@ class CRM_Core_Payment_PayPalImpl extends CRM_Core_Payment { $sub = empty($params['is_recur']) ? 'cgi-bin/webscr' : 'subscriptions'; $paypalURL = "{$url}{$sub}?$uri"; + // Allow each CMS to do a pre-flight check before redirecting to PayPal. + CRM_Utils_System::prePostRedirect(); + CRM_Utils_System::redirect($paypalURL); } diff --git a/CRM/Utils/SameSite.php b/CRM/Utils/SameSite.php new file mode 100644 index 0000000000..0431761aae --- /dev/null +++ b/CRM/Utils/SameSite.php @@ -0,0 +1,224 @@ +=') && + self::isChromiumVersionAtLeast(67, $userAgent, '<='); + } + + /** + * Detect iOS version. + * + * @param int $major The major version to test. + * @param str $userAgent The User Agent. + * @return bool + */ + private static function isIosVersion($major, $userAgent) { + $regex = "/\(iP.+; CPU .*OS (\d+)[_\d]*.*\) AppleWebKit\//"; + $matched = []; + + if (preg_match($regex, $userAgent, $matched)) { + // Extract digits from first capturing group. + $version = (int) $matched[1]; + return version_compare($version, $major, '<='); + } + + return FALSE; + } + + /** + * Detect MacOS version. + * + * @param int $major The major version to test. + * @param int $minor The minor version to test. + * @param str $userAgent The User Agent. + * @return bool + */ + private static function isMacosxVersion($major, $minor, $userAgent) { + $regex = "/\(Macintosh;.*Mac OS X (\d+)_(\d+)[_\d]*.*\) AppleWebKit\//"; + $matched = []; + + if (preg_match($regex, $userAgent, $matched)) { + // Extract digits from first and second capturing groups. + return version_compare((int) $matched[1], $major, '=') && + version_compare((int) $matched[2], $minor, '<='); + } + + return FALSE; + } + + /** + * Detect MacOS Safari. + * + * @param str $userAgent The User Agent. + * @return bool + */ + private static function isSafari($userAgent) { + $regex = "/Version\/.* Safari\//"; + return preg_match($regex, $userAgent) && !self::isChromiumBased($userAgent); + } + + /** + * Detect MacOS embedded browser. + * + * @param str $userAgent The User Agent. + * @return FALSE|int + */ + private static function isMacEmbeddedBrowser($userAgent) { + $regex = "/^Mozilla\/[\.\d]+ \(Macintosh;.*Mac OS X [_\d]+\) AppleWebKit\/[\.\d]+ \(KHTML, like Gecko\)$/"; + return preg_match($regex, $userAgent); + } + + /** + * Detect if browser is Chromium based. + * + * @param str $userAgent The User Agent. + * @return FALSE|int + */ + private static function isChromiumBased($userAgent) { + $regex = "/Chrom(e|ium)/"; + return preg_match($regex, $userAgent); + } + + /** + * Detect if Chromium version meets requirements. + * + * @param int $major The major version to test. + * @param str $userAgent The User Agent. + * @param str $operator + * @return bool|int + */ + private static function isChromiumVersionAtLeast($major, $userAgent, $operator) { + $regex = "/Chrom[^ \/]+\/(\d+)[\.\d]* /"; + $matched = []; + + if (preg_match($regex, $userAgent, $matched)) { + // Extract digits from first capturing group. + $version = (int) $matched[1]; + return version_compare($version, $major, $operator); + } + return FALSE; + } + + /** + * Detect UCBrowser. + * + * @param str $userAgent The User Agent. + * @return FALSE|int + */ + private static function isUcBrowser($userAgent) { + $regex = "/UCBrowser\//"; + return preg_match($regex, $userAgent); + } + + /** + * Detect if UCBrowser version meets requirements. + * + * @param int $major The major version to test. + * @param int $minor The minor version to test. + * @param int $build The build version to test. + * @param str $userAgent The User Agent. + * @return bool|int + */ + private static function isUcBrowserVersionAtLeast($major, $minor, $build, $userAgent) { + $regex = "/UCBrowser\/(\d+)\.(\d+)\.(\d+)[\.\d]* /"; + $matched = []; + + if (preg_match($regex, $userAgent, $matched)) { + // Extract digits from three capturing groups. + $majorVersion = (int) $matched[1]; + $minorVersion = (int) $matched[2]; + $buildVersion = (int) $matched[3]; + + if (version_compare($majorVersion, $major, '>=')) { + if (version_compare($minorVersion, $minor, '>=')) { + return version_compare($buildVersion, $build, '>='); + } + } + } + + return FALSE; + } + +} diff --git a/CRM/Utils/System.php b/CRM/Utils/System.php index 20b266d680..6af92e60ca 100644 --- a/CRM/Utils/System.php +++ b/CRM/Utils/System.php @@ -1915,4 +1915,12 @@ class CRM_Utils_System { $config = CRM_Core_Config::singleton()->userSystem->sendResponse($response); } + /** + * Perform any necessary actions prior to redirecting via POST. + */ + public static function prePostRedirect() { + $config = CRM_Core_Config::singleton(); + $config->userSystem->paypalBeforeRedirect(); + } + } diff --git a/CRM/Utils/System/Base.php b/CRM/Utils/System/Base.php index 5c4f1d58bb..aabb9aa48b 100644 --- a/CRM/Utils/System/Base.php +++ b/CRM/Utils/System/Base.php @@ -998,4 +998,10 @@ abstract class CRM_Utils_System_Base { return FALSE; } + /** + * Perform any necessary actions prior to redirecting via POST. + */ + public function prePostRedirect() { + } + } diff --git a/CRM/Utils/System/WordPress.php b/CRM/Utils/System/WordPress.php index e82b2e93df..f13ad38830 100644 --- a/CRM/Utils/System/WordPress.php +++ b/CRM/Utils/System/WordPress.php @@ -1087,4 +1087,161 @@ class CRM_Utils_System_WordPress extends CRM_Utils_System_Base { } } + /** + * Perform any necessary actions prior to redirecting via POST. + * + * Redirecting via POST means that cookies need to be sent with SameSite=None. + */ + public function prePostRedirect() { + // Get User Agent string. + $rawUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; + $userAgent = mb_convert_encoding($rawUserAgent, 'UTF-8'); + + // Bail early if User Agent does not support `SameSite=None`. + $shouldUseSameSite = CRM_Utils_SameSite::shouldSendSameSiteNone($userAgent); + if (!$shouldUseSameSite) { + return; + } + + // Make sure session cookie is present in header. + $cookie_params = session_name() . '=' . session_id() . '; SameSite=None; Secure'; + CRM_Utils_System::setHttpHeader('Set-Cookie', $cookie_params); + + // Add WordPress auth cookies when user is logged in. + $user = wp_get_current_user(); + if ($user->exists()) { + self::setAuthCookies($user->ID, TRUE, TRUE); + } + } + + /** + * Explicitly set WordPress authentication cookies. + * + * Chrome 84 introduced a cookie policy change which prevents cookies for the + * session and for WordPress user authentication from being indentified when + * a purchaser returns to the site from PayPal using the "Back to Merchant" + * button. + * + * In order to comply with this policy, cookies need to be sent with their + * "SameSite" attribute set to "None" and with the "Secure" flag set, but this + * isn't possible to do via `wp_set_auth_cookie()` as it stands. + * + * This method is a modified clone of `wp_set_auth_cookie()` which satisfies + * the Chrome policy. + * + * @see wp_set_auth_cookie() + * + * The $remember parameter increases the time that the cookie will be kept. The + * default the cookie is kept without remembering is two days. When $remember is + * set, the cookies will be kept for 14 days or two weeks. + * + * @param int $user_id The WordPress User ID. + * @param bool $remember Whether to remember the user. + * @param bool|string $secure Whether the auth cookie should only be sent over + * HTTPS. Default is an empty string which means the + * value of `is_ssl()` will be used. + * @param string $token Optional. User's session token to use for this cookie. + */ + private function setAuthCookies($user_id, $remember = FALSE, $secure = '', $token = '') { + if ($remember) { + /** This filter is documented in wp-includes/pluggable.php */ + $expiration = time() + apply_filters('auth_cookie_expiration', 14 * DAY_IN_SECONDS, $user_id, $remember); + + /* + * Ensure the browser will continue to send the cookie after the expiration time is reached. + * Needed for the login grace period in wp_validate_auth_cookie(). + */ + $expire = $expiration + (12 * HOUR_IN_SECONDS); + } + else { + /** This filter is documented in wp-includes/pluggable.php */ + $expiration = time() + apply_filters('auth_cookie_expiration', 2 * DAY_IN_SECONDS, $user_id, $remember); + $expire = 0; + } + + if ('' === $secure) { + $secure = is_ssl(); + } + + // Front-end cookie is secure when the auth cookie is secure and the site's home URL is forced HTTPS. + $secure_logged_in_cookie = $secure && 'https' === parse_url(get_option('home'), PHP_URL_SCHEME); + + /** This filter is documented in wp-includes/pluggable.php */ + $secure = apply_filters('secure_auth_cookie', $secure, $user_id); + + /** This filter is documented in wp-includes/pluggable.php */ + $secure_logged_in_cookie = apply_filters('secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure); + + if ($secure) { + $auth_cookie_name = SECURE_AUTH_COOKIE; + $scheme = 'secure_auth'; + } + else { + $auth_cookie_name = AUTH_COOKIE; + $scheme = 'auth'; + } + + if ('' === $token) { + $manager = WP_Session_Tokens::get_instance($user_id); + $token = $manager->create($expiration); + } + + $auth_cookie = wp_generate_auth_cookie($user_id, $expiration, $scheme, $token); + $logged_in_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $token); + + /** This filter is documented in wp-includes/pluggable.php */ + do_action('set_auth_cookie', $auth_cookie, $expire, $expiration, $user_id, $scheme, $token); + + /** This filter is documented in wp-includes/pluggable.php */ + do_action('set_logged_in_cookie', $logged_in_cookie, $expire, $expiration, $user_id, 'logged_in', $token); + + /** This filter is documented in wp-includes/pluggable.php */ + if (!apply_filters('send_auth_cookies', TRUE)) { + return; + } + + $base_options = [ + 'expires' => $expire, + 'domain' => COOKIE_DOMAIN, + 'httponly' => TRUE, + 'samesite' => 'None', + ]; + + self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => PLUGINS_COOKIE_PATH]); + self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => ADMIN_COOKIE_PATH]); + self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => COOKIEPATH]); + if (COOKIEPATH != SITECOOKIEPATH) { + self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => SITECOOKIEPATH]); + } + } + + /** + * Set cookie with "SameSite" flag. + * + * The method here is compatible with all versions of PHP. Needed because it + * is only as of PHP 7.3.0 that the setcookie() method supports the "SameSite" + * attribute in its options and will accept "None" as a valid value. + * + * @param $name The name of the cookie. + * @param $value The value of the cookie. + * @param array $options The header options for the cookie. + */ + private function setAuthCookie($name, $value, $options) { + $header = 'Set-Cookie: '; + $header .= rawurlencode($name) . '=' . rawurlencode($value) . '; '; + $header .= 'expires=' . gmdate('D, d-M-Y H:i:s T', $options['expires']) . '; '; + $header .= 'Max-Age=' . max(0, (int) ($options['expires'] - time())) . '; '; + $header .= 'path=' . rawurlencode($options['path']) . '; '; + $header .= 'domain=' . rawurlencode($options['domain']) . '; '; + + if (!empty($options['secure'])) { + $header .= 'secure; '; + } + $header .= 'httponly; '; + $header .= 'SameSite=' . rawurlencode($options['samesite']); + + header($header, FALSE); + $_COOKIE[$name] = $value; + } + } -- 2.25.1