diff --git a/includes/common.inc b/includes/common.inc index 477ecc0..694366f 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -5061,20 +5061,24 @@ function drupal_get_private_key() { * @param $value * An additional value to base the token on. * - * The generated token is based on the session ID of the current user. Normally, + * The generated token is based on the session of the current user. Normally, * anonymous users do not have a session, so the generated token will be * different on every page request. To generate a token for users without a * session, manually start a session prior to calling this function. * * @return string - * A 43-character URL-safe token for validation, based on the user session ID, + * A 43-character URL-safe token for validation, based on the token seed, * the hash salt provided from drupal_get_hash_salt(), and the * 'drupal_private_key' configuration variable. * * @see drupal_get_hash_salt() */ function drupal_get_token($value = '') { - return drupal_hmac_base64($value, session_id() . drupal_get_private_key() . drupal_get_hash_salt()); + if (empty($_SESSION['csrf_token_seed'])) { + $_SESSION['csrf_token_seed'] = drupal_random_key(); + } + + return drupal_compute_token($_SESSION['csrf_token_seed'], $value); } /** @@ -5093,7 +5097,28 @@ function drupal_get_token($value = '') { */ function drupal_valid_token($token, $value = '', $skip_anonymous = FALSE) { global $user; - return (($skip_anonymous && $user->uid == 0) || ($token === drupal_get_token($value))); + if (!$skip_anonymous && empty($_SESSION['csrf_token_seed'])) { + return FALSE; + } + + return (($skip_anonymous && $user->uid == 0) || ($token === drupal_compute_token($_SESSION['csrf_token_seed'], $value))); +} + +/** + * Generates a token_based on $value, the token seed, and the private key. + * + * @param string $seed + * The per-session token seed. + * @param string $value + * (optional) An additional value to base the token on. + * + * @return string + * A 43-character URL-safe token for validation, based on the token seed, + * the hash salt provided by drupal_get_hash_salt(), and the + * 'drupal_private_key' configuration variable. + */ +function drupal_compute_token($seed, $value = '') { + return drupal_hmac_base64($value, $seed . drupal_get_private_key() . drupal_get_hash_salt()); } function _drupal_bootstrap_full() { diff --git a/includes/session.inc b/includes/session.inc index 9589e06..101d4a4 100644 --- a/includes/session.inc +++ b/includes/session.inc @@ -249,7 +249,7 @@ function drupal_session_initialize() { // anonymous users not use a session cookie unless something is stored in // $_SESSION. This allows HTTP proxies to cache anonymous pageviews. drupal_session_start(); - if (!empty($user->uid) || !empty($_SESSION)) { + if (!empty($user->uid) || !drupal_session_obsolete()) { drupal_page_is_cacheable(FALSE); } } @@ -307,7 +307,7 @@ function drupal_session_commit() { return; } - if (empty($user->uid) && empty($_SESSION)) { + if (empty($user->uid) && drupal_session_obsolete()) { // There is no session data to store, destroy the session if it was // previously started. if (drupal_session_started()) { @@ -374,6 +374,10 @@ function drupal_session_regenerate() { } session_id(drupal_random_key()); + if (!empty($_SESSION)) { + unset($_SESSION['csrf_token_seed']); + } + if (isset($old_session_id)) { $params = session_get_cookie_params(); $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; @@ -531,3 +535,25 @@ function drupal_save_session($status = NULL) { } return $save_session; } + +/** + * Determines whether the session contains user data. + * + * @return bool + * TRUE when the session does not contain any values and therefore can be + * destroyed. + */ +function drupal_session_obsolete() { + // Return early when $_SESSION is empty or not initialized. + if (empty($_SESSION)) { + return TRUE; + } + + // Ignore the CSRF token seed. + // + // Anonymous users should not get a CSRF token at any time, or if they do, + // then the originating code is responsible for cleaning up the session once + // obsolete. Since that is not guaranteed to be the case, this check force- + // ignores the CSRF token, so as to avoid performance regressions. + return count(array_diff_key($_SESSION, array('csrf_token_seed' => TRUE))) == 0; +} diff --git a/modules/simpletest/tests/session.test b/modules/simpletest/tests/session.test index 097503b..86f0038 100644 --- a/modules/simpletest/tests/session.test +++ b/modules/simpletest/tests/session.test @@ -478,6 +478,69 @@ class SessionHttpsTestCase extends DrupalWebTestCase { } /** + * Ensure that a CSRF form token is shared in SSL mixed mode. + */ + protected function testCsrfTokenWithMixedModeSsl() { + if ($this->request->isSecure()) { + $secure_session_name = session_name(); + $insecure_session_name = substr(session_name(), 1); + } + else { + $secure_session_name = 'S' . session_name(); + $insecure_session_name = session_name(); + } + + // Enable mixed mode SSL. + variable_set('https', TRUE); + + $user = $this->drupalCreateUser(array('access administration pages')); + + // Login using the HTTPS user-login form. + $this->drupalGet('user'); + $form = $this->xpath('//form[@id="user-login-form"]'); + $form[0]['action'] = $this->httpsUrl('user'); + $edit = array('name' => $user->name, 'pass' => $user->pass_raw); + $this->drupalPostForm(NULL, $edit, t('Log in')); + + // Collect session id cookies. + $sid = $this->cookies[$insecure_session_name]['value']; + $ssid = $this->cookies[$secure_session_name]['value']; + $this->assertSessionIds($sid, $ssid, 'Session has both secure and insecure SIDs'); + + // Retrieve the form via HTTP. + $this->curlClose(); + $this->drupalGet($this->httpUrl('session-test/form'), array(), array('Cookie: ' . $insecure_session_name . '=' . $sid)); + $http_token = $this->getFormToken(); + + // Verify that submitting form values via HTTPS to a form originally + // retrieved over HTTP works. + $form = $this->xpath('//form[@id="session-test-form"]'); + $form[0]['action'] = $this->httpsUrl('session-test/form'); + $edit = array('input' => $this->randomName(32)); + $this->curlClose(); + $this->drupalPostForm(NULL, $edit, 'Save', array('Cookie: ' . $secure_session_name . '=' . $ssid)); + $this->assertText(format_string('Ok: @input', array('@input' => $edit['input']))); + + // Retrieve the same form via HTTPS. + $this->curlClose(); + $this->drupalGet($this->httpsUrl('session-test/form'), array(), array('Cookie: ' . $secure_session_name . '=' . $ssid)); + $https_token = $this->getFormToken(); + + // Verify that CSRF token values are the same for a form regardless of + // whether it was accessed via HTTP or HTTPS when SSL mixed mode is enabled. + $this->assertEqual($http_token, $https_token, 'Form token is the same on HTTP as well as HTTPS form'); + } + + /** + * Return the token of the current form. + */ + protected function getFormToken() { + $token_fields = $this->xpath('//input[@name="form_token"]'); + $this->assertEqual(count($token_fields), 1, 'One form token field on the page'); + return (string) $token_fields[0]['value']; + } + + /** * Test that there exists a session with two specific session IDs. * * @param $sid diff --git a/modules/simpletest/tests/session_test.module b/modules/simpletest/tests/session_test.module index 689ff09..4f22d7e 100644 --- a/modules/simpletest/tests/session_test.module +++ b/modules/simpletest/tests/session_test.module @@ -60,6 +60,13 @@ function session_test_menu() { 'access callback' => 'user_is_logged_in', 'type' => MENU_CALLBACK, ); + $items['session-test/form'] = array( + 'title' => 'Test form', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('session_test_form'), + 'access callback' => TRUE, + 'type' => MENU_CALLBACK, + ); return $items; } @@ -190,3 +197,28 @@ function session_test_drupal_goto_alter(&$path, &$options, &$http_response_code) function _session_test_is_logged_in() { return t('User is logged in.'); } + +/** + * Menu callback for the test config edit forms. +function session_test_form($form, &$form_state) { + $form['input'] = array( + '#type' => 'textfield', + '#title' => 'Input', + '#required' => TRUE, + ); + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => 'Save', + ); + + return $form; +} + +/** + * Submit callback. + */ +function session_test_form_submit(&$form, &$form_state) { + drupal_set_message(format_string('Ok: @input', array('@input' => $form_state['values']['input']))); +}