From 7850e41f03a55375ce1014b6ec2c30ead01a2527 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Thu, 25 Mar 2021 11:13:51 -0400 Subject: [PATCH] Support hCaptcha for VisualEditor Extend Captcha save error handler to verify user using hCaptcha. Bug: T264684 Change-Id: I88928e291a2ecd6edd74af62575e8704c0a2ee13 (cherry picked from commit 0993a43a290992d53624600f176d79a3ae8774dc) --- hCaptcha/extension.json | 33 ++++++++- hCaptcha/includes/HCaptcha.php | 67 +++++++++++++----- .../includes/Hooks/ResourceLoaderHooks.php | 29 ++++++++ .../ve-confirmedit-hCaptcha/.eslintrc.json | 6 ++ .../ve.init.mw.HCaptchaSaveErrorHandler.js | 68 +++++++++++++++++++ 5 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 hCaptcha/includes/Hooks/ResourceLoaderHooks.php create mode 100644 hCaptcha/resources/ve-confirmedit-hCaptcha/.eslintrc.json create mode 100644 hCaptcha/resources/ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js diff --git a/hCaptcha/extension.json b/hCaptcha/extension.json index ef7bb1569..daade7441 100644 --- a/hCaptcha/extension.json +++ b/hCaptcha/extension.json @@ -9,7 +9,7 @@ "license-name": "GPL-2.0-or-later", "type": "antispam", "MessagesDirs": { - "hNoCaptcha": [ + "HCaptcha": [ "i18n" ] }, @@ -37,5 +37,36 @@ "value": false } }, + "ConfigRegistry": { + "hcaptcha": "GlobalVarConfig::newInstance" + }, + "ResourceFileModulePaths": { + "localBasePath": "resources", + "remoteExtPath": "ConfirmEdit/hCaptcha/resources" + }, + "ResourceModules": { + "ext.confirmEdit.hCaptcha.visualEditor": { + "scripts": "ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js", + "targets": [ + "desktop", + "mobile" + ] + } + }, + "Hooks": { + "ResourceLoaderGetConfigVars": "resourceloader" + }, + "HookHandlers": { + "resourceloader": { + "class": "MediaWiki\\Extensions\\ConfirmEdit\\hCaptcha\\Hooks\\ResourceLoaderHooks" + } + }, + "attributes": { + "VisualEditor": { + "PluginModules": [ + "ext.confirmEdit.hCaptcha.visualEditor" + ] + } + }, "manifest_version": 2 } diff --git a/hCaptcha/includes/HCaptcha.php b/hCaptcha/includes/HCaptcha.php index b01fea854..491933523 100644 --- a/hCaptcha/includes/HCaptcha.php +++ b/hCaptcha/includes/HCaptcha.php @@ -5,12 +5,14 @@ namespace MediaWiki\Extensions\ConfirmEdit\hCaptcha; use ApiBase; use CaptchaAuthenticationRequest; use ConfirmEditHooks; +use ContentSecurityPolicy; use FormatJson; use Html; use MediaWiki\Auth\AuthenticationRequest; use MediaWiki\MediaWikiServices; use Message; use RawMessage; +use RequestContext; use SimpleCaptcha; use Status; use WebRequest; @@ -20,7 +22,17 @@ class HCaptcha extends SimpleCaptcha { // hcaptcha-create, hcaptcha-sendemail via getMessage() protected static $messagePrefix = 'hcaptcha-'; - private $error; + private $error = null; + + private $hCaptchaConfig; + + private $siteKey; + + public function __construct() { + $this->hCaptchaConfig = MediaWikiServices::getInstance()->getConfigFactory() + ->makeConfig( 'hcaptcha' ); + $this->siteKey = $this->hCaptchaConfig->get( 'HCaptchaSiteKey' ); + } /** * Get the captcha form. @@ -28,14 +40,12 @@ class HCaptcha extends SimpleCaptcha { * @return array */ public function getFormInformation( $tabIndex = 1 ) { - global $wgHCaptchaSiteKey; - $output = Html::element( 'div', [ 'class' => [ 'h-captcha', 'mw-confirmedit-captcha-fail' => (bool)$this->error, ], - 'data-sitekey' => $wgHCaptchaSiteKey + 'data-sitekey' => $this->siteKey ] ); return [ @@ -53,6 +63,22 @@ class HCaptcha extends SimpleCaptcha { return [ 'https://hcaptcha.com', 'https://*.hcaptcha.com' ]; } + /** + * Adds the CSP policies necessary for the captcha module to work in a CSP enforced + * setup. + * + * @param ContentSecurityPolicy $csp The CSP instance to add the policies to, usually + * obtained from {@link OutputPage::getCSP()} + */ + public static function addCSPSources( ContentSecurityPolicy $csp ) { + foreach ( static::getCSPUrls() as $src ) { + // Since frame-src is not supported + $csp->addDefaultSrc( $src ); + $csp->addScriptSrc( $src ); + $csp->addStyleSrc( $src ); + } + } + /** * @param Status|array|string $info */ @@ -74,7 +100,10 @@ class HCaptcha extends SimpleCaptcha { * @return array */ protected function getCaptchaParamsFromRequest( WebRequest $request ) { - $response = $request->getVal( 'h-captcha-response' ); + $response = $request->getVal( + 'h-captcha-response', + $request->getVal( 'captchaWord', $request->getVal( 'captchaword' ) ) + ); return [ '', $response ]; } @@ -89,15 +118,19 @@ class HCaptcha extends SimpleCaptcha { * @return bool */ protected function passCaptcha( $_, $token ) { - global $wgRequest, $wgHCaptchaSecretKey, $wgHCaptchaSendRemoteIP, $wgHCaptchaProxy; + $webRequest = RequestContext::getMain()->getRequest(); + + $secretKey = $this->hCaptchaConfig->get( 'HCaptchaSecretKey' ); + $sendRemoteIp = $this->hCaptchaConfig->get( 'HCaptchaSendRemoteIP' ); + $proxy = $this->hCaptchaConfig->get( 'HCaptchaProxy' ); $url = 'https://hcaptcha.com/siteverify'; $data = [ - 'secret' => $wgHCaptchaSecretKey, + 'secret' => $secretKey, 'response' => $token, ]; - if ( $wgHCaptchaSendRemoteIP ) { - $data['remoteip'] = $wgRequest->getIP(); + if ( $sendRemoteIp ) { + $data['remoteip'] = $webRequest->getIP(); } $options = [ @@ -105,8 +138,8 @@ class HCaptcha extends SimpleCaptcha { 'postData' => $data, ]; - if ( $wgHCaptchaProxy ) { - $options['proxy'] = $wgHCaptchaProxy; + if ( $proxy ) { + $options['proxy'] = $proxy; } $request = MediaWikiServices::getInstance()->getHttpRequestFactory() @@ -137,17 +170,18 @@ class HCaptcha extends SimpleCaptcha { * @param array &$resultArr */ protected function addCaptchaAPI( &$resultArr ) { + $resultArr['captcha'] = $this->describeCaptchaType(); + $resultArr['captcha']['error'] = $this->error; } /** * @return array */ public function describeCaptchaType() { - global $wgHCaptchaSiteKey; return [ 'type' => 'hcaptcha', 'mime' => 'application/javascript', - 'key' => $wgHCaptchaSiteKey, + 'key' => $this->siteKey, ]; } @@ -187,6 +221,8 @@ class HCaptcha extends SimpleCaptcha { * @inheritDoc */ public function storeCaptcha( $info ) { + // hCaptcha is stored externally, the ID will be generated at that time as well, and + // the one returned here won't be used. Just pretend this worked. return 'not used'; } @@ -202,6 +238,7 @@ class HCaptcha extends SimpleCaptcha { * @inheritDoc */ public function getCaptcha() { + // hCaptcha is handled by frontend code + an external provider; nothing to do here. return []; } @@ -221,8 +258,6 @@ class HCaptcha extends SimpleCaptcha { public function onAuthChangeFormFields( array $requests, array $fieldInfo, array &$formDescriptor, $action ) { - global $wgHCaptchaSiteKey; - $req = AuthenticationRequest::getRequestByClass( $requests, CaptchaAuthenticationRequest::class, @@ -237,7 +272,7 @@ class HCaptcha extends SimpleCaptcha { $formDescriptor['captchaWord'] = [ 'class' => HTMLHCaptchaField::class, - 'key' => $wgHCaptchaSiteKey, + 'key' => $this->siteKey, 'error' => $captcha->getError(), ] + $formDescriptor['captchaWord']; } diff --git a/hCaptcha/includes/Hooks/ResourceLoaderHooks.php b/hCaptcha/includes/Hooks/ResourceLoaderHooks.php new file mode 100644 index 000000000..ea21a8af0 --- /dev/null +++ b/hCaptcha/includes/Hooks/ResourceLoaderHooks.php @@ -0,0 +1,29 @@ +getConfigFactory()->makeConfig( 'hcaptcha' ); + if ( $hCaptchaConfig->get( 'CaptchaClass' ) === 'MediaWiki\\Extensions\\ConfirmEdit\\hCaptcha\\HCaptcha' ) { + $vars['wgConfirmEditConfig'] = [ + 'hCaptchaSiteKey' => $hCaptchaConfig->get( 'HCaptchaSiteKey' ), + 'hCaptchaScriptURL' => 'https://hcaptcha.com/1/api.js', + ]; + } + } +} diff --git a/hCaptcha/resources/ve-confirmedit-hCaptcha/.eslintrc.json b/hCaptcha/resources/ve-confirmedit-hCaptcha/.eslintrc.json new file mode 100644 index 000000000..2642795ec --- /dev/null +++ b/hCaptcha/resources/ve-confirmedit-hCaptcha/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "ve": false, + "OO": false + } +} diff --git a/hCaptcha/resources/ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js b/hCaptcha/resources/ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js new file mode 100644 index 000000000..d75f99951 --- /dev/null +++ b/hCaptcha/resources/ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js @@ -0,0 +1,68 @@ +mw.loader.using( 'ext.visualEditor.targetLoader' ).then( function () { + mw.libs.ve.targetLoader.addPlugin( function () { + ve.init.mw.HCaptchaSaveErrorHandler = function () {}; + + OO.inheritClass( ve.init.mw.HCaptchaSaveErrorHandler, ve.init.mw.SaveErrorHandler ); + + ve.init.mw.HCaptchaSaveErrorHandler.static.name = 'confirmEditHCaptcha'; + + ve.init.mw.HCaptchaSaveErrorHandler.static.getReadyPromise = function () { + var onLoadFn = 'onHcaptchaLoadCallback' + Date.now(), + deferred, config, scriptURL, params; + + if ( !this.readyPromise ) { + deferred = $.Deferred(); + config = mw.config.get( 'wgConfirmEditConfig' ); + scriptURL = new mw.Uri( config.hCaptchaScriptURL ); + params = { onload: onLoadFn, render: 'explicit' }; + scriptURL.query = $.extend( scriptURL.query, params ); + + this.readyPromise = deferred.promise(); + window[ onLoadFn ] = deferred.resolve; + mw.loader.load( scriptURL.toString() ); + } + + return this.readyPromise; + }; + + ve.init.mw.HCaptchaSaveErrorHandler.static.matchFunction = function ( data ) { + var captchaData = ve.getProp( data, 'visualeditoredit', 'edit', 'captcha' ); + + return !!( captchaData && captchaData.type === 'hcaptcha' ); + }; + + ve.init.mw.HCaptchaSaveErrorHandler.static.process = function ( data, target ) { + var self = this, + config = mw.config.get( 'wgConfirmEditConfig' ), + siteKey = config.hCaptchaSiteKey, + $container = $( '
' ); + + // Register extra fields + target.saveFields.wpCaptchaWord = function () { + // eslint-disable-next-line no-jquery/no-global-selector + return $( '[name=h-captcha-response]' ).val(); + }; + + this.getReadyPromise() + .then( function () { + // ProcessDialog's error system isn't great for this yet. + target.saveDialog.clearMessage( 'api-save-error' ); + target.saveDialog.showMessage( 'api-save-error', $container, { wrap: false } ); + self.widgetId = window.hcaptcha.render( $container[ 0 ], { + sitekey: siteKey, + callback: function () { + target.saveDialog.executeAction( 'save' ); + }, + 'expired-callback': function () {}, + 'error-callback': function () {} + } ); + target.saveDialog.popPending(); + target.saveDialog.updateSize(); + + target.emit( 'saveErrorCaptcha' ); + } ); + }; + + ve.init.mw.saveErrorHandlerFactory.register( ve.init.mw.HCaptchaSaveErrorHandler ); + } ); +} );