Issue #3170525 by mcdruid, nullkernel, simonholt83, MustangGB, Znak, axle_foley00, Fabianx, akorkot, cilefen, thalemn, Ayesh, ressa, finne: Set samesite cookie attribute for PHP sessions

merge-requests/471/head
mcdruid 2021-03-23 21:33:42 +00:00
parent 975e9c6040
commit 7ba88d9d4f
5 changed files with 356 additions and 4 deletions

View File

@ -3879,3 +3879,85 @@ function drupal_clear_opcode_cache($filepath) {
@apc_delete_file($filepath);
}
}
/**
* Drupal's wrapper around PHP's setcookie() function.
*
* This allows the cookie's $value and $options to be altered.
*
* @param $name
* The name of the cookie.
* @param $value
* The value of the cookie.
* @param $options
* An associative array which may have any of the keys expires, path, domain,
* secure, httponly, samesite.
*
* @see setcookie()
* @ingroup php_wrappers
*/
function drupal_setcookie($name, $value, $options) {
$options = _drupal_cookie_params($options);
if (\PHP_VERSION_ID >= 70300) {
setcookie($name, $value, $options);
}
else {
setcookie($name, $value, $options['expires'], $options['path'], $options['domain'], $options['secure'], $options['httponly']);
}
}
/**
* Process the params for cookies. This emulates support for the SameSite
* attribute in earlier versions of PHP, and allows the value of that attribute
* to be overridden.
*
* @param $options
* An associative array which may have any of the keys expires, path, domain,
* secure, httponly, samesite.
*
* @return
* An associative array which may have any of the keys expires, path, domain,
* secure, httponly, and samesite.
*/
function _drupal_cookie_params($options) {
$options['samesite'] = _drupal_samesite_cookie($options);
if (\PHP_VERSION_ID < 70300) {
// Emulate SameSite support in older PHP versions.
if (!empty($options['samesite'])) {
// Ensure the SameSite attribute is only added once.
if (!preg_match('/SameSite=/i', $options['path'])) {
$options['path'] .= '; SameSite=' . $options['samesite'];
}
}
}
return $options;
}
/**
* Determine the value for the samesite cookie attribute, in the following order
* of precedence:
*
* 1) A value explicitly passed to drupal_setcookie()
* 2) A value set in $conf['samesite_cookie_value']
* 3) The setting from php ini
* 4) The default of None, or FALSE (no attribute) if the cookie is not Secure
*
* @param $options
* An associative array as passed to drupal_setcookie().
* @return
* The value for the samesite cookie attribute.
*/
function _drupal_samesite_cookie($options) {
if (isset($options['samesite'])) {
return $options['samesite'];
}
$override = variable_get('samesite_cookie_value', NULL);
if ($override !== NULL) {
return $override;
}
$ini_options = session_get_cookie_params();
if (isset($ini_options['samesite'])) {
return $ini_options['samesite'];
}
return empty($options['secure']) ? FALSE : 'None';
}

View File

@ -284,6 +284,20 @@ function drupal_session_start() {
// Save current session data before starting it, as PHP will destroy it.
$session_data = isset($_SESSION) ? $_SESSION : NULL;
// Apply any overrides to the session cookie params.
$params = $original_params = session_get_cookie_params();
// PHP settings for samesite will be handled by _drupal_cookie_params().
unset($params['samesite']);
$params = _drupal_cookie_params($params);
if ($params !== $original_params) {
if (\PHP_VERSION_ID >= 70300) {
session_set_cookie_params($params);
}
else {
session_set_cookie_params($params['lifetime'], $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
}
session_start();
drupal_session_started(TRUE);
@ -323,7 +337,14 @@ function drupal_session_commit() {
$insecure_session_name = substr(session_name(), 1);
$params = session_get_cookie_params();
$expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
setcookie($insecure_session_name, $_COOKIE[$insecure_session_name], $expire, $params['path'], $params['domain'], FALSE, $params['httponly']);
$options = array(
'expires' => $expire,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => FALSE,
'httponly' => $params['httponly'],
);
drupal_setcookie($insecure_session_name, $_COOKIE[$insecure_session_name], $options);
}
}
// Write the session data.
@ -365,7 +386,14 @@ function drupal_session_regenerate() {
// $params['lifetime'] seconds from the current request. If it is not set,
// it will expire when the browser is closed.
$expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
setcookie($insecure_session_name, $session_id, $expire, $params['path'], $params['domain'], FALSE, $params['httponly']);
$options = array(
'expires' => $expire,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => FALSE,
'httponly' => $params['httponly'],
);
drupal_setcookie($insecure_session_name, $session_id, $options);
$_COOKIE[$insecure_session_name] = $session_id;
}
@ -380,7 +408,14 @@ function drupal_session_regenerate() {
if (isset($old_session_id)) {
$params = session_get_cookie_params();
$expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
setcookie(session_name(), session_id(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
$options = array(
'expires' => $expire,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
);
drupal_setcookie(session_name(), session_id(), $options);
$fields = array('sid' => session_id());
if ($is_https) {
$fields['ssid'] = session_id();
@ -488,7 +523,14 @@ function _drupal_session_delete_cookie($name, $secure = NULL) {
if ($secure !== NULL) {
$params['secure'] = $secure;
}
setcookie($name, '', REQUEST_TIME - 3600, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
$options = array(
'expires' => REQUEST_TIME - 3600,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
);
drupal_setcookie($name, '', $options);
unset($_COOKIE[$name]);
}
}

View File

@ -244,6 +244,187 @@ class SessionTestCase extends DrupalWebTestCase {
$this->assertResponse(403, 'An empty session ID is not allowed.');
}
/**
* Test absence of SameSite attribute on session cookies by default.
*/
function testNoSameSiteCookieAttributeDefault() {
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
if (\PHP_VERSION_ID < 70300) {
$this->drupalLogin($user);
}
else {
// PHP often defaults to an empty value for session.cookie_samesite but
// that may vary, so we set an explicit empty value.
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: *EMPTY*';
$edit = array(
'name' => $user->name,
'pass' => $user->pass_raw,
);
$this->drupalPost('user', $edit, t('Log in'), array(), $headers);
}
$this->assertFalse(preg_match('/SameSite=/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie has no SameSite attribute (default).');
}
/**
* Test SameSite attribute = None by default on Secure session cookies.
*/
function testSameSiteCookieAttributeNoneSecure() {
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
$headers = array();
if (\PHP_VERSION_ID >= 70300) {
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: None';
}
// Test HTTPS session handling by altering the form action to submit the
// login form through https.php, which creates a mock HTTPS request.
$this->drupalGet('user');
$form = $this->xpath('//form[@id="user-login"]');
$form[0]['action'] = $this->httpsUrl('user');
$edit = array('name' => $user->name, 'pass' => $user->pass_raw);
$this->drupalPost(NULL, $edit, t('Log in'), array(), $headers);
$this->assertTrue(preg_match('/SameSite=None/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=None.');
}
/**
* Test SameSite attribute = None on session cookies.
*/
function testSameSiteCookieAttributeNone() {
variable_set('samesite_cookie_value', 'None');
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
$this->drupalLogin($user);
$this->assertTrue(preg_match('/SameSite=None/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=None.');
}
/**
* Test SameSite attribute = Lax on session cookies.
*/
function testSameSiteCookieAttributeLax() {
variable_set('samesite_cookie_value', 'Lax');
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
$this->drupalLogin($user);
$this->assertTrue(preg_match('/SameSite=Lax/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=Lax.');
}
/**
* Test SameSite attribute = Strict on session cookies.
*/
function testSameSiteCookieAttributeStrict() {
variable_set('samesite_cookie_value', 'Strict');
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
$this->drupalLogin($user);
$this->assertTrue(preg_match('/SameSite=Strict/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=Strict.');
}
/**
* Test disabling the samesite attribute on session cookies via $conf
*/
function testSameSiteCookieAttributeDisabledViaConf() {
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
variable_set('samesite_cookie_value', FALSE);
if (\PHP_VERSION_ID < 70300) {
// There is no session.cookie_samesite in earlier PHP versions.
$this->drupalLogin($user);
}
else {
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: Lax';
$edit = array(
'name' => $user->name,
'pass' => $user->pass_raw,
);
$this->drupalPost('user', $edit, t('Log in'), array(), $headers);
}
$this->assertFalse(preg_match('/SameSite=/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie has no SameSite attribute (conf).');
}
/**
* Test disabling the samesite attribute on session cookies via php ini
*/
function testSameSiteCookieAttributeDisabledViaPhpIni() {
if (\PHP_VERSION_ID < 70300) {
// There is no session.cookie_samesite in earlier PHP versions.
$this->pass('This test is only for PHP 7.3 and later.');
return;
}
$user = $this->drupalCreateUser(array('access content'));
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: *EMPTY*';
$edit = array(
'name' => $user->name,
'pass' => $user->pass_raw,
);
$this->drupalPost('user', $edit, t('Log in'), array(), $headers);
$this->assertFalse(preg_match('/SameSite=/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie has no SameSite attribute (ini).');
}
/**
* Test that a PHP setting for session.cookie_samesite is not overridden by
* the default value in Drupal, without a samesite_cookie_value variable.
*/
function testSamesiteCookiePhpSettingLax() {
if (\PHP_VERSION_ID < 70300) {
// There is no session.cookie_samesite in earlier PHP versions.
$this->pass('This test is only for PHP 7.3 and later.');
return;
}
$user = $this->drupalCreateUser(array('access content'));
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: Lax';
$edit = array(
'name' => $user->name,
'pass' => $user->pass_raw,
);
$this->drupalPost('user', $edit, t('Log in'), array(), $headers);
$this->assertTrue(preg_match('/SameSite=Lax/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=Lax.');
}
/**
* Test overriding the PHP setting for session.cookie_samesite with the
* samesite_cookie_value variable.
*/
function testSamesiteCookieOverrideLaxToStrict() {
if (\PHP_VERSION_ID < 70300) {
// There is no session.cookie_samesite in earlier PHP versions.
$this->pass('This test is only for PHP 7.3 and later.');
return;
}
variable_set('samesite_cookie_value', 'Strict');
$user = $this->drupalCreateUser(array('access content'));
// Send our own login POST so that we can pass a custom header to trigger
// session_test.module to call ini_set('session.cookie_samesite', $value)
$headers[] = 'X-Session-Cookie-Ini-Set: Lax';
$edit = array(
'name' => $user->name,
'pass' => $user->pass_raw,
);
$this->drupalPost('user', $edit, t('Log in'), array(), $headers);
$this->assertTrue(preg_match('/SameSite=Strict/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie is set as SameSite=Strict.');
}
/**
* Test SameSite attribute = Lax on set-cookie header on logout.
*/
function testSamesiteCookieLogoutLax() {
variable_set('samesite_cookie_value', 'Lax');
$user = $this->drupalCreateUser(array('access content'));
$this->sessionReset($user->uid);
$this->drupalLogin($user);
$this->drupalGet('user/logout');
$this->assertTrue(preg_match('/SameSite=Lax/i', $this->drupalGetHeader('Set-Cookie', TRUE)), 'Session cookie deletion includes SameSite=Lax.');
}
/**
* Reset the cookie file so that it refers to the specified user.
*
@ -285,6 +466,20 @@ class SessionTestCase extends DrupalWebTestCase {
$this->assertIdentical($this->drupalGetHeader('X-Session-Empty'), '0', 'Session was not empty.');
}
}
/**
* Builds a URL for submitting a mock HTTPS request to HTTP test environments.
*
* @param $url
* A Drupal path such as 'user'.
*
* @return
* An absolute URL.
*/
protected function httpsUrl($url) {
global $base_url;
return $base_url . '/modules/simpletest/tests/https.php?q=' . $url;
}
}
/**

View File

@ -64,6 +64,19 @@ function session_test_menu() {
return $items;
}
/**
* It's very unusual to do anything outside of a function / hook, but in this
* case we want to simulate a given session.cookie_samesite setting in php.ini
* or via ini_set() in settings.php. This would almost never be a good idea
* outside of a test scenario.
*/
if (isset($_SERVER['HTTP_X_SESSION_COOKIE_INI_SET'])) {
if (in_array($_SERVER['HTTP_X_SESSION_COOKIE_INI_SET'], array('None', 'Lax', 'Strict', '*EMPTY*'))) {
$value = ($_SERVER['HTTP_X_SESSION_COOKIE_INI_SET'] == '*EMPTY*') ? '' : $_SERVER['HTTP_X_SESSION_COOKIE_INI_SET'];
ini_set('session.cookie_samesite', $value);
}
}
/**
* Implements hook_boot().
*/

View File

@ -725,3 +725,23 @@ $conf['field_sql_storage_skip_writing_unchanged_fields'] = TRUE;
* @see drupal_mail()
*/
$conf['mail_display_name_site_name'] = TRUE;
/**
* SameSite cookie attribute.
*
* This variable can be used to set a value for the SameSite cookie attribute.
*
* Versions of PHP before 7.3 have no native support for the SameSite attribute
* so it is emulated.
*
* The session.cookie-samesite setting in PHP 7.3 and later will be overridden
* by this variable for Drupal session cookies, and any other cookies managed
* with drupal_setcookie().
*
* Setting this variable to FALSE disables the SameSite attribute on cookies.
*
* @see drupal_setcookie()
* @see drupal_session_start()
* @see https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-samesite
*/
#$conf['samesite_cookie_value'] = 'None';