Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions captcha.inc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* @param string $form_id
* the form ID to configure.
*
* @param string $captcha_type
* @param string|object|null $captcha_type
* the setting for the given form_id, can be:
* - 'none' to disable CAPTCHA,
* - 'default' to use the default challenge type
Expand Down Expand Up @@ -69,9 +69,9 @@ function captcha_set_form_id_setting($form_id, $captcha_type) {
* @param bool $symbolic
* flag to return as (symbolic) strings instead of object.
*
* @return NULL
* @return string|array|null
* if no setting is known
* or a captcha_point object with fields 'module' and 'captcha_type'.
* or a captcha_point array with fields 'module' and 'captcha_type'.
* If argument $symbolic is true, returns (symbolic) as 'none', 'default'
* or in the form 'captcha/Math'.
*/
Expand Down Expand Up @@ -119,7 +119,8 @@ function captcha_get_form_id_setting($form_id, $symbolic = FALSE) {
/**
* Helper function to load all captcha points.
*
* @return array of all captcha_points
* @return array
* All captcha_points.
*/
function captcha_get_captcha_points() {

Expand All @@ -141,13 +142,13 @@ function captcha_get_captcha_points() {
* Helper function for generating a new CAPTCHA session.
*
* @param string $form_id
* the form_id of the form to add a CAPTCHA to.
* The form_id of the form to add a CAPTCHA to.
*
* @param int $status
* the initial status of the CAPTHCA session.
* The initial status of the CAPTHCA session.
*
* @return int
* the session ID of the new CAPTCHA session.
* The session ID of the new CAPTCHA session.
*/
function _captcha_generate_captcha_session($form_id = NULL, $status = CAPTCHA_STATUS_UNSOLVED) {
global $user;
Expand All @@ -173,10 +174,10 @@ function _captcha_generate_captcha_session($form_id = NULL, $status = CAPTCHA_ST
* Helper function for updating the solution in the CAPTCHA session table.
*
* @param int $captcha_sid
* the CAPTCHA session ID to update.
* The CAPTCHA session ID to update.
*
* @param string $solution
* the new solution to associate with the given CAPTCHA session.
* The new solution to associate with the given CAPTCHA session.
*/
function _captcha_update_captcha_session($captcha_sid, $solution) {
db_update('captcha_sessions')
Expand All @@ -193,6 +194,13 @@ function _captcha_update_captcha_session($captcha_sid, $solution) {
*
* Based on the CAPTCHA persistence setting, the CAPTCHA session ID and
* user session info.
*
* @param int $captcha_sid
* Session ID.
* @param int $form_id
* The Form ID.
*
* @return bool
*/
function _captcha_required_for_user($captcha_sid, $form_id) {
// Get the CAPTCHA persistence setting.
Expand Down Expand Up @@ -312,7 +320,7 @@ function _captcha_parse_captcha_type($captcha_type) {
return explode('/', $captcha_type);
}

/*
/**
* Formats _captcha_parse_captcha_type for config
*
* @param string $form_id
Expand Down
53 changes: 43 additions & 10 deletions captcha.module
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ function captcha_element_process($element, &$form_state, $complete_form) {
}

/**
* Implementation of hook_captcha_default_points_alter().
* Implements hook_captcha_default_points_alter().
*
* Provide some default captchas only if defaults are not already
* provided by other modules.
Expand Down Expand Up @@ -376,7 +376,7 @@ function theme_captcha($variables) {
}

/**
* Implements of hook_form_alter().
* Implements hook_form_alter().
*
* This function adds a CAPTCHA to forms for untrusted users if needed and adds
* CAPTCHA administration links for site administrators if this option is enabled.
Expand Down Expand Up @@ -698,15 +698,31 @@ function captcha_validate($element, &$form_state) {
// Get CAPTCHA response.
$captcha_response = $form_state['values']['captcha_response'];

// Get CAPTCHA session from CAPTCHA info
// Retrieve the solution for the CAPTCHA session.
// @see https://www.drupal.org/project/captcha/issues/3558256
// TODO: is this correct in all cases: see comment and code in previous revisions?
$csid = $captcha_info['captcha_sid'];

$solution = db_query(
'SELECT solution FROM {captcha_sessions} WHERE csid = :csid',
array(':csid' => $csid)
// For non-"show always" persistence, prevent retrieval of solutions for
// solved sessions to block replay attacks. Once a CAPTCHA is solved, its
// solution can no longer be retrieved, requiring each form submission to
// solve a fresh CAPTCHA. For "show always", the session must remain
// readable since the CAPTCHA is shown on every form submission.
$captcha_persistence = config_get('captcha.settings', 'persistence');
if ($captcha_persistence != CAPTCHA_PERSISTENCE_SHOW_ALWAYS) {
$solution = db_query(
'SELECT solution FROM {captcha_sessions} WHERE csid = :csid AND status <> :status',
array(':csid' => $csid, ':status' => CAPTCHA_STATUS_SOLVED)
)
->fetchField();
}
else {
$solution = db_query(
'SELECT solution FROM {captcha_sessions} WHERE csid = :csid',
array(':csid' => $csid)
)
->fetchField();
}

// @todo: what is the result when there is no entry for the captcha_session? in D6 it was FALSE, what in D7?
if ($solution === FALSE) {
Expand All @@ -732,15 +748,18 @@ function captcha_validate($element, &$form_state) {
// Correct answer.

// Store form_id in session (but only if it is useful to do so, avoid setting stuff in session unnecessarily).
$captcha_persistence = config_get('captcha.settings', 'persistence');
if ($captcha_persistence == CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL || $captcha_persistence == CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_TYPE) {
$_SESSION['captcha_success_form_ids'][$form_id] = $form_id;
}

// Record success.
// Record success and invalidate token to prevent replay attacks.
// @see https://www.drupal.org/project/captcha/issues/3558256
db_update('captcha_sessions')
->condition('csid', $csid)
->fields(array('status' => CAPTCHA_STATUS_SOLVED))
->fields(array(
'status' => CAPTCHA_STATUS_SOLVED,
'token' => NULL,
))
->expression('attempts', 'attempts + 1')
->execute();
}
Expand Down Expand Up @@ -814,6 +833,20 @@ function captcha_pre_render_process($element) {
// Update captcha_sessions table: store the solution of the generated CAPTCHA.
_captcha_update_captcha_session($captcha_sid, $captcha_info['solution']);

// For "show always" persistence, regenerate the token after validation
// since it may have been invalidated to prevent replay attacks. This
// ensures the rendered form has a valid token for the next submission.
// @see https://www.drupal.org/project/captcha/issues/3558256
$captcha_persistence = config_get('captcha.settings', 'persistence');
if ($captcha_persistence == CAPTCHA_PERSISTENCE_SHOW_ALWAYS) {
$new_token = md5(mt_rand());
db_update('captcha_sessions')
->fields(array('token' => $new_token))
->condition('csid', $captcha_sid)
->execute();
$element['captcha_token']['#value'] = $new_token;
}

// Handle the response field if it is available and if it is a textfield.
if (isset($element['captcha_widgets']['captcha_response']['#type']) && $element['captcha_widgets']['captcha_response']['#type'] == 'textfield') {
// Before rendering: presolve an admin mode challenge or
Expand All @@ -831,7 +864,7 @@ function captcha_pre_render_process($element) {
}

/**
* Default implementation of hook_captcha().
* Implements hook_captcha().
*/
function captcha_captcha($op, $captcha_type = '') {
switch ($op) {
Expand Down
149 changes: 147 additions & 2 deletions tests/captcha.test
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ class CaptchaTestCase extends CaptchaBaseWebTestCase {
* a challenge is generated and added to the form but removed in the pre_render phase.
* The CAPTCHA description should not show up either.
*
* \see testCaptchaSessionReuseOnNodeForms()
* @see testCaptchaSessionReuseOnNodeForms()
*/
function testCaptchaDescriptionAfterCommentPreview() {
// Set Test CAPTCHA on comment form.
Expand All @@ -359,7 +359,7 @@ class CaptchaTestCase extends CaptchaBaseWebTestCase {
* Test if the CAPTCHA session ID is reused when the node form is reloaded:
* node form reload after correct response should not show CAPTCHA anymore.
*
* \see testCaptchaDescriptionAfterCommentPreview()
* @see testCaptchaDescriptionAfterCommentPreview()
*/
function testCaptchaSessionReuseOnNodeForms() {
// Set Test CAPTCHA on page form.
Expand Down Expand Up @@ -942,6 +942,9 @@ class CaptchaSessionReuseAttackTestCase extends CaptchaBaseWebTestCase {
* Assert that the CAPTCHA session ID reuse attack was detected.
*/
protected function assertCaptchaSessionIdReuseAttackDetection() {
// The token is being invalidated now whenever there is a correct response,
// so unsure if this will assert all the time.
// @see https://www.drupal.org/project/captcha/issues/3558256
$this->assertText(t(CAPTCHA_SESSION_REUSE_ATTACK_ERROR_MESSAGE),
'CAPTCHA session ID reuse attack should be detected.',
'CAPTCHA');
Expand Down Expand Up @@ -1101,6 +1104,148 @@ class CaptchaSessionReuseAttackTestCase extends CaptchaBaseWebTestCase {

}

class CaptchaTokenInvalidationTestCase extends CaptchaBaseWebTestCase {

/**
* Tests that replay attacks are blocked after successful CAPTCHA validation.
*/
public function testReplayAttackIsBlocked() {
// Create a CAPTCHA session.
$captcha_sid = _captcha_generate_captcha_session('test_form');

// Generate and store a token.
$captcha_token = md5(mt_rand());
db_update('captcha_sessions')
->fields(['token' => $captcha_token])
->condition('csid', $captcha_sid)
->execute();

// Set a known solution.
$solution = 'Test 123';
_captcha_update_captcha_session($captcha_sid, $solution);

// First submission.
$element = $this->createCaptchaElement();
$form_state = $this->createFormState($captcha_sid, $solution);

captcha_validate($element, $form_state);

// First validation should pass (no errors).
$this->assertFalse(
form_get_errors(),
'First CAPTCHA submission should pass without errors.'
);

// Replay attack - call captcha_validate() again with same session.
$replay_form_state = $this->createFormState($captcha_sid, $solution);

captcha_validate($element, $replay_form_state);

$this->assertTrue(
form_get_errors(),
'Replay attack should be blocked - CAPTCHA session was already used.'
);
}

/**
* Tests that CAPTCHA token is invalidated after successful validation.
*/
public function testTokenInvalidatedAfterSuccessfulValidation() {
// Create a CAPTCHA session.
$captcha_sid = _captcha_generate_captcha_session('test_form');

// Generate and store a token.
$captcha_token = md5(mt_rand());
db_update('captcha_sessions')
->fields(['token' => $captcha_token])
->condition('csid', $captcha_sid)
->execute();

// Set a known solution.
$solution = 'Test 123';
_captcha_update_captcha_session($captcha_sid, $solution);

// Verify token exists before validation.
$token_before = $this->getSessionToken($captcha_sid);
$this->assertNotNull($token_before, 'Token should exist before validation.');

// Call actual captcha_validate() with correct solution.
$element = $this->createCaptchaElement();
$form_state = $this->createFormState($captcha_sid, $solution);

captcha_validate($element, $form_state);

// Validation should pass.
$this->assertFalse(form_get_errors(), 'Validation should pass.');

// Check token after validation.
$token_after = $this->getSessionToken($captcha_sid);

$this->assertTrue(
empty($token_after),
'Token should be invalidated after successful CAPTCHA validation.'
);
}

/**
* Creates a CAPTCHA form element for testing.
*
* @return array
* The form element array.
*/
protected function createCaptchaElement() {
return [
'#captcha_validate' => 'captcha_validate_strict_equality',
'#captcha_info' => [
'form_id' => 'test_form',
],
];
}

/**
* Creates a form state for CAPTCHA validation.
*
* @param int $captcha_sid
* The CAPTCHA session ID.
* @param string $response
* The user's CAPTCHA response.
*
* @return array
* The configured form state.
*/
protected function createFormState(int $captcha_sid, string $response) {
// Set captcha_info (required by captcha_validate).
$form_state['captcha_info'] = [
'this_form_id' => 'test_form',
'captcha_sid' => $captcha_sid,
'captcha_type' => 'Test',
'module' => 'captcha',
'access' => TRUE,
];

// Set the user's response.
$form_state['values']['captcha_response'] = $response;

return $form_state;
}

/**
* Gets the token for a CAPTCHA session.
*
* @param int $csid
* The CAPTCHA session ID.
*
* @return string|false
* The token, or FALSE if not found/empty.
*/
protected function getSessionToken(int $csid) {
return db_select('captcha_sessions', 'cs')
->fields('cs', ['token'])
->condition('csid', $csid)
->execute()
->fetchField();
}
}

// Some tricks to debug:
// backdrop_debug($data) // from devel module
Expand Down
6 changes: 6 additions & 0 deletions tests/captcha.tests.info
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ name = CAPTCHA session reuse attack tests
description = Testing of the protection against CAPTCHA session reuse attacks.
group = CAPTCHA
file = captcha.test

[CaptchaTokenInvalidationTestCase]
name = CAPTCHA token invalidation tests
description = Testing of the protection against CAPTCHA session reuse attacks.
group = CAPTCHA
file = captcha.test