--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * SameSite Utility Class.
+ *
+ * Determines if the current User Agent can handle the `SameSite=None` parameter
+ * by mapping against known incompatible clients.
+ *
+ * Sample code:
+ *
+ * // Get User Agent string.
+ * $rawUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
+ * $userAgent = mb_convert_encoding($rawUserAgent, 'UTF-8');
+ *
+ * // Get boolean representing User Agent compatibility.
+ * $shouldUseSameSite = CRM_Utils_SameSite::shouldSendSameSiteNone($userAgent);
+ *
+ * Based on code provided by "The Chromium Projects".
+ *
+ * @see https://www.chromium.org/updates/same-site/incompatible-clients
+ */
+class CRM_Utils_SameSite {
+
+ /**
+ * Determine if the current User Agent can handle the `SameSite=None` parameter.
+ *
+ * @param str $userAgent The User Agent.
+ * @return bool True if the User Agent is compatible, FALSE otherwise.
+ */
+ public static function shouldSendSameSiteNone($userAgent) {
+ return !self::isSameSiteNoneIncompatible($userAgent);
+ }
+
+ /**
+ * Detect classes of browsers known to be incompatible.
+ *
+ * @param str $userAgent The User Agent.
+ * @return bool True if the User Agent is determined to be incompatible, FALSE otherwise.
+ */
+ private static function isSameSiteNoneIncompatible($userAgent) {
+ return self::hasWebKitSameSiteBug($userAgent) ||
+ self::dropsUnrecognizedSameSiteCookies($userAgent);
+ }
+
+ /**
+ * Detect versions of Safari and embedded browsers on MacOS 10.14 and all
+ * browsers on iOS 12.
+ *
+ * These versions will erroneously treat cookies marked with `SameSite=None`
+ * as if they were marked `SameSite=Strict`.
+ *
+ * @param str $userAgent The User Agent.
+ * @return bool
+ */
+ private static function hasWebKitSameSiteBug($userAgent) {
+ return self::isIosVersion(12, $userAgent) || (self::isMacosxVersion(10, 14, $userAgent) &&
+ (self::isSafari($userAgent) || self::isMacEmbeddedBrowser($userAgent)));
+ }
+
+ /**
+ * Detect versions of UC Browser on Android prior to version 12.13.2.
+ *
+ * Older versions will reject a cookie with `SameSite=None`. This behavior was
+ * correct according to the version of the cookie specification at that time,
+ * but with the addition of the new "None" value to the specification, this
+ * behavior has been updated in newer versions of UC Browser.
+ *
+ * @param str $userAgent The User Agent.
+ * @return bool
+ */
+ private static function dropsUnrecognizedSameSiteCookies($userAgent) {
+ if (self::isUcBrowser($userAgent)) {
+ return !self::isUcBrowserVersionAtLeast(12, 13, 2, $userAgent);
+ }
+
+ return self::isChromiumBased($userAgent) &&
+ self::isChromiumVersionAtLeast(51, $userAgent, '>=') &&
+ 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;
+ }
+
+}
}
}
+ /**
+ * 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;
+ }
+
}