Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.04% covered (danger)
1.04%
1 / 96
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Turnstile
1.04% covered (danger)
1.04%
1 / 96
6.25% covered (danger)
6.25%
1 / 16
733.46
0.00% covered (danger)
0.00%
0 / 1
 getFormInformation
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getCSPUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logCheckError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCaptchaParamsFromRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 passCaptcha
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 addCaptchaAPI
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 describeCaptchaType
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 apiGetAllowedParams
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getApiParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 retrieveCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createAuthenticationRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\ConfirmEdit\Turnstile;
6
7use MediaWiki\Api\ApiBase;
8use MediaWiki\Auth\AuthenticationRequest;
9use Mediawiki\Context\RequestContext;
10use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest;
11use MediaWiki\Extension\ConfirmEdit\Services\CaptchaFactory;
12use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
13use MediaWiki\Html\Html;
14use MediaWiki\Json\FormatJson;
15use MediaWiki\Language\RawMessage;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Message\Message;
18use MediaWiki\Output\OutputPage;
19use MediaWiki\Request\WebRequest;
20use MediaWiki\Status\Status;
21use MediaWiki\User\UserIdentity;
22
23class Turnstile extends SimpleCaptcha {
24    /**
25     * @var string used for turnstile-edit, turnstile-addurl, turnstile-badlogin, turnstile-createaccount,
26     * turnstile-create, turnstile-sendemail via getMessage()
27     */
28    protected static $messagePrefix = 'turnstile';
29
30    /** @var string|null */
31    private $error = null;
32
33    /** @inheritDoc */
34    public function getFormInformation( $tabIndex = 1, ?OutputPage $out = null ) {
35        global $wgTurnstileSiteKey;
36
37        if ( $out !== null ) {
38            $lang = $out->getLanguage();
39        } else {
40            // TODO Deprecate this after migrating callers to provide output page
41            $lang = RequestContext::getMain()->getLanguage();
42        }
43        $langCode = htmlspecialchars( urlencode( $lang->getCode() ) );
44
45        $output = Html::element( 'div', [
46            'class' => [
47                'cf-turnstile',
48                'mw-confirmedit-captcha-fail' => (bool)$this->error,
49            ],
50            'data-sitekey' => $wgTurnstileSiteKey
51        ] );
52        return [
53            'html' => $output,
54            'headitems' => [
55                // Insert the Turnstile script, in display language, if available.
56                // Language falls back to the browser's display language.
57                // See https://developers.cloudflare.com/turnstile/reference/supported-languages/
58                "<script src=\"https://challenges.cloudflare.com/turnstile/v0/api.js?language={$langCode}\" async defer>
59                </script>"
60            ]
61        ];
62    }
63
64    /** @inheritDoc */
65    public static function getCSPUrls() {
66        return [ 'https://challenges.cloudflare.com/turnstile/v0/api.js' ];
67    }
68
69    /**
70     * @param Status|array|string $info
71     */
72    protected function logCheckError( $info ) {
73        if ( $info instanceof Status ) {
74            $errors = $info->getErrorsArray();
75            $error = $errors[0][0];
76        } elseif ( is_array( $info ) ) {
77            $error = implode( ',', $info );
78        } else {
79            $error = $info;
80        }
81
82        wfDebugLog( 'captcha', 'Unable to validate response: ' . $error );
83    }
84
85    /**
86     * @param WebRequest $request
87     * @return array
88     */
89    protected function getCaptchaParamsFromRequest( WebRequest $request ) {
90        // Turnstile combines captcha ID + solution into a single value
91        // API is hardwired to return captchaWord, so use that if the standard isempty
92        // "captchaWord" is sent as "captchaword" by visual editor
93        $index = 'not used';
94        $response = $request->getVal(
95            'cf-turnstile-response',
96            $request->getVal( 'captchaWord', $request->getVal( 'captchaword' ) )
97        );
98        return [ $index, $response ];
99    }
100
101    /**
102     * Check if the user solved the captcha.
103     *
104     * Based on reference implementation:
105     * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
106     *
107     * @param mixed $_ Not used
108     * @param string $word captcha solution
109     * @param UserIdentity $user
110     * @return bool
111     */
112    protected function passCaptcha( $_, $word, $user ) {
113        global $wgRequest, $wgTurnstileSecretKey, $wgTurnstileSendRemoteIP;
114
115        $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
116        // Build data to append to request
117        $data = [
118            'secret' => $wgTurnstileSecretKey,
119            'response' => $word,
120        ];
121        if ( $wgTurnstileSendRemoteIP ) {
122            $data['remoteip'] = $wgRequest->getIP();
123        }
124        $request = MediaWikiServices::getInstance()->getHttpRequestFactory()
125            ->create( $url, [ 'method' => 'POST' ], __METHOD__ );
126        $request->setData( $data );
127        $status = $request->execute();
128        if ( !$status->isOK() ) {
129            $this->error = 'http';
130            $this->logCheckError( $status );
131            return false;
132        }
133        $response = FormatJson::decode( $request->getContent(), true );
134        if ( !$response ) {
135            $this->error = 'json';
136            $this->logCheckError( $this->error );
137            return false;
138        }
139        // Turnstile always returns the "error-codes" array, so we should just
140        // check whether it is empty or not.
141        if ( !empty( $response['error-codes'] ) ) {
142            $this->error = 'turnstile-api';
143            $this->logCheckError( $response['error-codes'] );
144            return false;
145        }
146
147        return $response['success'];
148    }
149
150    /** @inheritDoc */
151    protected function addCaptchaAPI( &$resultArr ) {
152        $resultArr['captcha'] = $this->describeCaptchaType( $this->action );
153        $resultArr['captcha']['error'] = $this->error;
154    }
155
156    /** @inheritDoc */
157    public function describeCaptchaType( ?string $action = null ) {
158        global $wgTurnstileSiteKey;
159        return [
160            'type' => 'turnstile',
161            'mime' => 'application/javascript',
162            'key' => $wgTurnstileSiteKey,
163        ];
164    }
165
166    /**
167     * Show a message asking the user to enter a captcha on edit
168     * The result will be treated as wiki text
169     *
170     * @param string $action Action being performed
171     * @return Message
172     */
173    public function getMessage( $action ) {
174        $msg = parent::getMessage( $action );
175        if ( $this->error ) {
176            $msg = new RawMessage( '<div class="error">$1</div>', [ $msg ] );
177        }
178        return $msg;
179    }
180
181    /**
182     * @param ApiBase $module
183     * @param array &$params
184     * @param int $flags
185     * @return bool
186     */
187    public function apiGetAllowedParams( ApiBase $module, &$params, $flags ) {
188        if ( $flags && $this->isAPICaptchaModule( $module ) ) {
189            $params['cf-turnstile-response'] = [
190                ApiBase::PARAM_HELP_MSG => 'turnstile-apihelp-param-cf-turnstile-response',
191            ];
192        }
193
194        return true;
195    }
196
197    /** @inheritDoc */
198    public function getApiParams(): array {
199        return [ 'cf-turnstile-response' ];
200    }
201
202    /** @inheritDoc */
203    public function getError() {
204        return $this->error;
205    }
206
207    /** @inheritDoc */
208    public function storeCaptcha( $info ) {
209        return 'not used';
210    }
211
212    /** @inheritDoc */
213    public function retrieveCaptcha( $index ) {
214        // Pretend it worked
215        return [ 'index' => $index ];
216    }
217
218    /** @inheritDoc */
219    public function getCaptcha() {
220        // Turnstile is handled by frontend code + an external provider; nothing to do here.
221        return [];
222    }
223
224    /**
225     * @return TurnstileAuthenticationRequest
226     */
227    public function createAuthenticationRequest() {
228        return new TurnstileAuthenticationRequest();
229    }
230
231    /**
232     * @param array $requests
233     * @param array $fieldInfo
234     * @param array &$formDescriptor
235     * @param string $action
236     */
237    public function onAuthChangeFormFields(
238        array $requests, array $fieldInfo, array &$formDescriptor, $action
239    ) {
240        global $wgTurnstileSiteKey;
241
242        /** @var CaptchaAuthenticationRequest $req */
243        $req = AuthenticationRequest::getRequestByClass(
244            $requests,
245            CaptchaAuthenticationRequest::class,
246            true
247        );
248        if ( !$req ) {
249            return;
250        }
251
252        // ugly way to retrieve error information
253        /** @var CaptchaFactory $captchaFactory */
254        $captchaFactory = MediaWikiServices::getInstance()->get( 'ConfirmEditCaptchaFactory' );
255        $captcha = $captchaFactory->getGlobalInstanceFromAuthenticationRequest(
256            $req,
257            RequestContext::getMain()->getRequest()->getSession()
258        );
259
260        $formDescriptor['captchaWord'] = [
261            'class' => HTMLTurnstileField::class,
262            'key' => $wgTurnstileSiteKey,
263            'error' => $captcha->getError(),
264        ] + $formDescriptor['captchaWord'];
265    }
266}