Commit c4bfbde3 authored by kimpepper's avatar kimpepper Committed by Jakob Perry
Browse files

Issue #3102288 by Berdir, iyyappan.govind, dhirendra.mishra, lhridley,...

Issue #3102288 by Berdir, iyyappan.govind, dhirendra.mishra, lhridley, swatichouhan012, andreyjan, naveenvalecha, Neslee Canil Pinto, kim.pepper, ankushgautam76@gmail.com: Prepare the module for Drupal 9
parent 6731656f
...@@ -2,7 +2,7 @@ name: CAPTCHA ...@@ -2,7 +2,7 @@ name: CAPTCHA
type: module type: module
description: Provides the CAPTCHA API for adding challenges to arbitrary forms. description: Provides the CAPTCHA API for adding challenges to arbitrary forms.
package: Spam control package: Spam control
core: 8.x core_version_requirement: ^8.8 || ^9
configure: captcha_settings configure: captcha_settings
dependencies: dependencies:
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
* to solve. * to solve.
*/ */
use Drupal\captcha\Element\Captcha;
use Drupal\captcha\Entity\CaptchaPoint; use Drupal\captcha\Entity\CaptchaPoint;
use Drupal\Core\Database\Database; use Drupal\Core\Database\Database;
use Drupal\Core\Form\BaseFormIdInterface; use Drupal\Core\Form\BaseFormIdInterface;
...@@ -564,39 +565,11 @@ function captcha_validate($element, FormStateInterface &$form_state) { ...@@ -564,39 +565,11 @@ function captcha_validate($element, FormStateInterface &$form_state) {
* *
* @return array * @return array
* The manipulated element. * The manipulated element.
*
* @deprecated Use \Drupal\captcha\Element\Captcha::preRenderProcess() instead.
*/ */
function captcha_pre_render_process(array $element) { function captcha_pre_render_process(array $element) {
module_load_include('inc', 'captcha'); return Captcha::preRenderProcess($element);
// Get form and CAPTCHA information.
$captcha_info = $element['#captcha_info'];
$form_id = $captcha_info['form_id'];
$captcha_sid = (int) ($captcha_info['captcha_sid']);
// Check if CAPTCHA is still required.
// This check is done in a first phase during the element processing
// (@see captcha_process), but it is also done here for better support
// of multi-page forms. Take previewing a node submission for example:
// when the challenge is solved correctely on preview, the form is still
// not completely submitted, but the CAPTCHA can be skipped.
if (_captcha_required_for_user($captcha_sid, $form_id) || $element['#captcha_admin_mode']) {
// Update captcha_sessions table: store the solution
// of the generated CAPTCHA.
_captcha_update_captcha_session($captcha_sid, $captcha_info['solution']);
// 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
// empty the value of the captcha_response form item.
$value = $element['#captcha_admin_mode'] ? $captcha_info['solution'] : '';
$element['captcha_widgets']['captcha_response']['#value'] = $value;
}
}
else {
// Remove CAPTCHA widgets from form.
unset($element['captcha_widgets']);
}
return $element;
} }
/** /**
......
{ {
"name": "drupal/captcha", "name": "drupal/captcha",
"description": "The CAPTCHA module provides this feature to virtually any user facing web form on a Drupal site.", "description": "The CAPTCHA module provides this feature to virtually any user facing web form on a Drupal site.",
"type": "drupal-module" "type": "drupal-module",
"homepage": "https://www.drupal.org/project/captcha",
"support": {
"issues": "https://www.drupal.org/project/issues/captcha"
},
"require": {
"drupal/core": "^8.8 || ^9"
},
"extra": {
"branch-alias": {
"dev-8.x-1.x": "1.x-dev"
}
}
} }
...@@ -2,7 +2,7 @@ name: Image CAPTCHA ...@@ -2,7 +2,7 @@ name: Image CAPTCHA
type: module type: module
description: Provides an image based CAPTCHA. description: Provides an image based CAPTCHA.
package: Spam control package: Spam control
core: 8.x core_version_requirement: ^8.8 || ^9
dependencies: dependencies:
- captcha - captcha
configure: admin/config/people/captcha/image_captcha configure: admin/config/people/captcha/image_captcha
...@@ -2,5 +2,5 @@ name: captcha long form id test module ...@@ -2,5 +2,5 @@ name: captcha long form id test module
type: module type: module
description: 'Test module for testing captchas added to forms with ids longer than 64 characters' description: 'Test module for testing captchas added to forms with ids longer than 64 characters'
package: Testing package: Testing
core: 8.x core_version_requirement: ^8.8 || ^9
hidden: true hidden: true
...@@ -221,7 +221,7 @@ class Captcha extends FormElement implements ContainerFactoryPluginInterface { ...@@ -221,7 +221,7 @@ class Captcha extends FormElement implements ContainerFactoryPluginInterface {
if (!isset($element['#pre_render'])) { if (!isset($element['#pre_render'])) {
$element['#pre_render'] = []; $element['#pre_render'] = [];
} }
$element['#pre_render'][] = 'captcha_pre_render_process'; $element['#pre_render'][] = [Captcha::class, 'preRenderProcess'];
// Store the solution in the #captcha_info array. // Store the solution in the #captcha_info array.
$element['#captcha_info']['solution'] = $captcha['solution']; $element['#captcha_info']['solution'] = $captcha['solution'];
...@@ -248,4 +248,51 @@ class Captcha extends FormElement implements ContainerFactoryPluginInterface { ...@@ -248,4 +248,51 @@ class Captcha extends FormElement implements ContainerFactoryPluginInterface {
return $element; return $element;
} }
/**
* Pre-render callback for additional processing of a CAPTCHA form element.
*
* This encompasses tasks that should happen after the general FAPI processing
* (building, submission and validation) but before rendering
* (e.g. storing the solution).
*
* @param array $element
* The CAPTCHA form element.
*
* @return array
* The manipulated element.
*/
public static function preRenderProcess(array $element) {
module_load_include('inc', 'captcha');
// Get form and CAPTCHA information.
$captcha_info = $element['#captcha_info'];
$form_id = $captcha_info['form_id'];
$captcha_sid = (int) ($captcha_info['captcha_sid']);
// Check if CAPTCHA is still required.
// This check is done in a first phase during the element processing
// (@see captcha_process), but it is also done here for better support
// of multi-page forms. Take previewing a node submission for example:
// when the challenge is solved correctely on preview, the form is still
// not completely submitted, but the CAPTCHA can be skipped.
if (_captcha_required_for_user($captcha_sid, $form_id) || $element['#captcha_admin_mode']) {
// Update captcha_sessions table: store the solution
// of the generated CAPTCHA.
_captcha_update_captcha_session($captcha_sid, $captcha_info['solution']);
// 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
// empty the value of the captcha_response form item.
$value = $element['#captcha_admin_mode'] ? $captcha_info['solution'] : '';
$element['captcha_widgets']['captcha_response']['#value'] = $value;
}
}
else {
// Remove CAPTCHA widgets from form.
unset($element['captcha_widgets']);
}
return $element;
}
} }
...@@ -306,6 +306,8 @@ class CaptchaAdminTest extends CaptchaWebTestBase { ...@@ -306,6 +306,8 @@ class CaptchaAdminTest extends CaptchaWebTestBase {
// Check in database. // Check in database.
$result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id);
$this->assertInstanceOf(CaptchaPoint::class, $result, 'Disabled CAPTCHA point should be in database');
$this->assertFalse($result->status());
// Set CAPTCHA point via admin/user/captcha/captcha/captcha_point/$form_id. // Set CAPTCHA point via admin/user/captcha/captcha/captcha_point/$form_id.
$form_values = [ $form_values = [
...@@ -325,7 +327,7 @@ class CaptchaAdminTest extends CaptchaWebTestBase { ...@@ -325,7 +327,7 @@ class CaptchaAdminTest extends CaptchaWebTestBase {
'Deleting of CAPTCHA point'); 'Deleting of CAPTCHA point');
$result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id);
$this->assertFalse($result, 'Deleted CAPTCHA point should be in database'); $this->assertNull($result, 'Deleted CAPTCHA point should not be in database');
} }
/** /**
......
...@@ -40,7 +40,7 @@ class CaptchaCacheTest extends CaptchaWebTestBase { ...@@ -40,7 +40,7 @@ class CaptchaCacheTest extends CaptchaWebTestBase {
captcha_set_form_id_setting('user_login_form', 'captcha/Math'); captcha_set_form_id_setting('user_login_form', 'captcha/Math');
$this->drupalGet(''); $this->drupalGet('');
$sid = $this->getCaptchaSidFromForm(); $sid = $this->getCaptchaSidFromForm();
$this->assertFalse($this->drupalGetHeader('x-drupal-cache'), 'Cache is disabled'); $this->assertNull($this->drupalGetHeader('x-drupal-cache'), 'Cache is disabled');
$this->drupalGet(''); $this->drupalGet('');
$this->assertNotEqual($sid, $this->getCaptchaSidFromForm()); $this->assertNotEqual($sid, $this->getCaptchaSidFromForm());
...@@ -48,18 +48,18 @@ class CaptchaCacheTest extends CaptchaWebTestBase { ...@@ -48,18 +48,18 @@ class CaptchaCacheTest extends CaptchaWebTestBase {
captcha_set_form_id_setting('user_login_form', 'captcha/Test'); captcha_set_form_id_setting('user_login_form', 'captcha/Test');
$this->drupalGet(''); $this->drupalGet('');
$sid = $this->getCaptchaSidFromForm(); $sid = $this->getCaptchaSidFromForm();
$this->assertFalse($this->drupalGetHeader('x-drupal-cache'), 'Cache is disabled'); $this->assertNull($this->drupalGetHeader('x-drupal-cache'), 'Cache is disabled');
$this->drupalGet(''); $this->drupalGet('');
$this->assertNotEqual($sid, $this->getCaptchaSidFromForm()); $this->assertNotEqual($sid, $this->getCaptchaSidFromForm());
// Switch challenge to image_captcha/Image, check the captcha isn't cached. // Switch challenge to image_captcha/Image, check the captcha isn't cached.
captcha_set_form_id_setting('user_login_form', 'image_captcha/Image'); captcha_set_form_id_setting('user_login_form', 'image_captcha/Image');
$this->drupalGet(''); $this->drupalGet('');
$image_path = $this->xpath('//div[@class="details-wrapper"]/img')[0]->getAttribute('src'); $image_path = $this->getSession()->getPage()->find('css', '.captcha img')->getAttribute('src');
$this->assertFalse($this->drupalGetHeader('x-drupal-cache'), 'Cache disabled'); $this->assertNull($this->drupalGetHeader('x-drupal-cache'), 'Cache disabled');
// Check that we get a new image when vising the page again. // Check that we get a new image when vising the page again.
$this->drupalGet(''); $this->drupalGet('');
$this->assertNotEqual($image_path, $this->xpath('//div[@class="details-wrapper"]/img')[0]->getAttribute('src')); $this->assertNotEqual($image_path, $this->getSession()->getPage()->find('css', '.captcha img')->getAttribute('src'));
// Check image caching, remove the base path since drupalGet() expects the // Check image caching, remove the base path since drupalGet() expects the
// internal path. // internal path.
$this->drupalGet(substr($image_path, strlen($base_path))); $this->drupalGet(substr($image_path, strlen($base_path)));
......
...@@ -19,6 +19,11 @@ class CaptchaCronTest extends BrowserTestBase { ...@@ -19,6 +19,11 @@ class CaptchaCronTest extends BrowserTestBase {
*/ */
public static $modules = ['captcha']; public static $modules = ['captcha'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/** /**
* Temporary captcha sessions storage. * Temporary captcha sessions storage.
* *
......
<?php <?php
namespace Drupal\captcha\Tests; namespace Drupal\Tests\captcha\Functional;
use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\Core\Session\AccountInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\simpletest\WebTestBase;
/** /**
* Tests CAPTCHA session reusing. * Tests CAPTCHA session reusing.
* *
* @group captcha * @group captcha
*/ */
class CaptchaSessionReuseAttackTestCase extends WebTestBase { class CaptchaSessionReuseAttackTestCase extends CaptchaWebTestBase {
use CommentTestTrait;
/**
* Wrong response error message.
*/
const CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE = 'The answer you entered for the CAPTCHA was not correct.';
/**
* Unknown CSID error message.
*/
const CAPTCHA_UNKNOWN_CSID_ERROR_MESSAGE = 'CAPTCHA validation error: unknown CAPTCHA session ID. Contact the site administrator if this problem persists.';
/**
* Modules to install for this Test class.
*
* @var array
*/
public static $modules = ['captcha', 'comment'];
/**
* User with various administrative permissions.
*
* @var \Drupal\user\Entity\User
*/
protected $adminUser;
/**
* Normal visitor with limited permissions.
*
* @var \Drupal\user\Entity\User
*/
protected $normalUser;
/**
* Form ID of comment form on standard (page) node.
*/
const COMMENT_FORM_ID = 'comment_comment_form';
const LOGIN_HTML_FORM_ID = 'user-login-form';
/**
* Drupal path of the (general) CAPTCHA admin page.
*/
const CAPTCHA_ADMIN_PATH = 'admin/config/people/captcha';
/**
* {@inheritdoc}
*/
public function setUp() {
// Load two modules: the captcha module itself and the comment
// module for testing anonymous comments.
parent::setUp();
module_load_include('inc', 'captcha');
$this->drupalCreateContentType(['type' => 'page']);
// Create a normal user.
$permissions = [
'access comments',
'post comments',
'skip comment approval',
'access content',
'create page content',
'edit own page content',
];
$this->normalUser = $this->drupalCreateUser($permissions);
// Create an admin user.
$permissions[] = 'administer CAPTCHA settings';
$permissions[] = 'skip CAPTCHA';
$permissions[] = 'administer permissions';
$permissions[] = 'administer content types';
$this->adminUser = $this->drupalCreateUser($permissions);
// Open comment for page content type.
$this->addDefaultCommentField('node', 'page');
// Put comments on page nodes on a separate page.
$comment_field = FieldConfig::loadByName('node', 'page', 'comment');
$comment_field->setSetting('form_location', CommentItemInterface::FORM_SEPARATE_PAGE);
$comment_field->save();
/* @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */
$captcha_point = \Drupal::entityTypeManager()
->getStorage('captcha_point')
->load('user_login_form');
$captcha_point->enable()->save();
$this->config('captcha.settings')
->set('default_challenge', 'captcha/test')
->save();
}
/**
* Assert that the response is accepted.
*
* No "unknown CSID" message, no "CSID reuse attack detection" message,
* No "wrong answer" message.
*/
protected function assertCaptchaResponseAccepted() {
// There should be no error message about unknown CAPTCHA session ID.
$this->assertNoText(self::CAPTCHA_UNKNOWN_CSID_ERROR_MESSAGE,
'CAPTCHA response should be accepted (known CSID).',
'CAPTCHA'
);
// There should be no error message about wrong response.
$this->assertNoText(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE,
'CAPTCHA response should be accepted (correct response).',
'CAPTCHA'
);
}
/**
* Assert that there is a CAPTCHA on the form or not.
*
* @param bool $presence
* Whether there should be a CAPTCHA or not.
*/
protected function assertCaptchaPresence($presence) {
if ($presence) {
$this->assertText(_captcha_get_description(),
'There should be a CAPTCHA on the form.', 'CAPTCHA'
);
}
else {
$this->assertNoText(_captcha_get_description(),
'There should be no CAPTCHA on the form.', 'CAPTCHA'
);
}
}
/**
* Helper function to generate a form values array for comment forms.
*/
protected function getCommentFormValues() {
$edit = [
'subject[0][value]' => 'comment_subject ' . $this->randomMachineName(32),
'comment_body[0][value]' => 'comment_body ' . $this->randomMachineName(256),
];
return $edit;
}
/**
* Helper function to generate a form values array for node forms.
*/
protected function getNodeFormValues() {
$edit = [
'title[0][value]' => 'node_title ' . $this->randomMachineName(32),
'body[0][value]' => 'node_body ' . $this->randomMachineName(256),
];
return $edit;
}
/**
* Get the CAPTCHA session id from the current form in the browser.
*
* @param null|string $form_html_id
* HTML form id attribute.
*
* @return int
* Captcha SID integer.
*/
protected function getCaptchaSidFromForm($form_html_id = NULL) {
if (!$form_html_id) {
$elements = $this->xpath('//input[@name="captcha_sid"]');
}
else {
$elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="captcha_sid"]');
}
$captcha_sid = (int) $elements[0]['value'];
return $captcha_sid;
}
/**
* Get the CAPTCHA token from the current form in the browser.
*
* @param null|string $form_html_id
* HTML form id attribute.
*
* @return int
* Captcha token integer.
*/
protected function getCaptchaTokenFromForm($form_html_id = NULL) {
if (!$form_html_id) {
$elements = $this->xpath('//input[@name="captcha_token"]');
}
else {
$elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="captcha_token"]');
}
$captcha_token = (int) $elements[0]['value'];
return $captcha_token;
}
/**
* Get the solution of the math CAPTCHA from the current form in the browser.
*
* @param null|string $form_html_id
* HTML form id attribute.
*
* @return int
* Calculated Math solution.
*/
protected function getMathCaptchaSolutionFromForm($form_html_id = NULL) {
// Get the math challenge.
if (!$form_html_id) {
$elements = $this->xpath('//div[contains(@class, "form-item-captcha-response")]/span[@class="field-prefix"]');
}
else {
$elements = $this->xpath('//form[@id="' . $form_html_id . '"]//div[contains(@class, "form-item-captcha-response")]/span[@class="field-prefix"]');
}
$this->assert('pass', json_encode($elements));
$challenge = (string) $elements[0];
$this->assert('pass', $challenge);
// Extract terms and operator from challenge.
$matches = [];
preg_match('/\\s*(\\d+)\\s*(-|\\+)\\s*(\\d+)\\s*=\\s*/', $challenge, $matches);
// Solve the challenge.
$a = (int) $matches[1];
$b = (int) $matches[3];
$solution = $matches[2] == '-' ? $a - $b : $a + $b;
return $solution;
}
/**
* Helper function to allow comment posting for anonymous users.
*/
protected function allowCommentPostingForAnonymousVisitors() {
// Enable anonymous comments.
user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, [
'access comments',
'post comments',
'skip comment approval',
]);
}
/** /**
* Assert that the CAPTCHA session ID reuse attack was detected. * Assert that the CAPTCHA session ID reuse attack was detected.
...@@ -298,12 +52,15 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase { ...@@ -298,12 +52,15 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase {
$this->assertCaptchaResponseAccepted(); $this->assertCaptchaResponseAccepted();
$this->assertCaptchaPresence(FALSE); $this->assertCaptchaPresence(FALSE);
// Go to comment form of commentable node again.
$this->drupalGet('comment/reply/node/' . $node->id() . '/comment');
// Post a new comment, reusing the previous CAPTCHA session. // Post a new comment, reusing the previous CAPTCHA session.
$edit = $this->getCommentFormValues(); $edit = $this->getCommentFormValues();
$edit['captcha_sid'] = $captcha_sid; $this->assertSession()->hiddenFieldExists("captcha_sid")->setValue((string) $captcha_sid);
$edit['captcha_token'] = $captcha_token; $this->assertSession()->hiddenFieldExists("captcha_token")->setValue((string) $captcha_token);
$edit['captcha_response'] = $solution; $edit['captcha_response'] = $solution;
$this->drupalPostForm('comment/reply/node/' . $node->id() . '/comment', $edit, t('Preview')); $this->drupalPostForm(NULL, $edit, t('Preview'));
// CAPTCHA session reuse attack should be detected. // CAPTCHA session reuse attack should be detected.
$this->assertCaptchaSessionIdReuseAttackDetection(); $this->assertCaptchaSessionIdReuseAttackDetection();
// There should be a CAPTCHA. // There should be a CAPTCHA.
...@@ -342,12 +99,16 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase { ...@@ -342,12 +99,16 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase {
// Check that there is no CAPTCHA after preview. // Check that there is no CAPTCHA after preview.
$this->assertCaptchaPresence(FALSE); $this->assertCaptchaPresence(FALSE);
// Post a new comment, reusing the previous CAPTCHA session. // Go to node add form again.
$this->drupalGet('node/add/page');
$this->assertCaptchaPresence(TRUE);
// Post a new node, reusing the previous CAPTCHA session.
$edit = $this->getNodeFormValues(); $edit = $this->getNodeFormValues();
$edit['captcha_sid'] = $captcha_sid; $this->assertSession()->hiddenFieldExists("captcha_sid")->setValue((string) $captcha_sid);
$edit['captcha_token'] = $captcha_token; $this->assertSession()->hiddenFieldExists("captcha_token")->setValue((string) $captcha_token);
$edit['captcha_response'] = $solution; $edit['captcha_response'] = $solution;
$this->drupalPostForm('node/add/page', $edit, t('Preview')); $this->drupalPostForm(NULL, $edit, t('Preview'));
// CAPTCHA session reuse attack should be detected. // CAPTCHA session reuse attack should be detected.
$this->assertCaptchaSessionIdReuseAttackDetection(); $this->assertCaptchaSessionIdReuseAttackDetection();
// There should be a CAPTCHA. // There should be a CAPTCHA.
...@@ -379,7 +140,7 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase { ...@@ -379,7 +140,7 @@ class CaptchaSessionReuseAttackTestCase extends WebTestBase {
'pass' => $this->normalUser->pass_raw,