Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
57.23% |
293 / 512 |
|
52.63% |
30 / 57 |
CRAP | |
0.00% |
0 / 1 |
| SimpleCaptcha | |
57.23% |
293 / 512 |
|
52.63% |
30 / 57 |
2349.51 | |
0.00% |
0 / 1 |
| setAction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setTrigger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getError | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getCaptcha | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getActivatedCaptchas | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addCaptchaAPI | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| describeCaptchaType | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| getFormInformation | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
2 | |||
| getCSPUrls | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| addCSPSources | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| addFormToOutput | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| addFormInformationToOutput | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
| getCaptchaInfo | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| showEditFormFields | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| editShowCaptcha | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
3.10 | |||
| getMessage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| injectEmailUser | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
| canIPBypassCaptcha | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| getWikiIPBypassList | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
| buildValidIPs | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| keyMatch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| triggersCaptcha | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
8 | |||
| shouldCheck | |
67.00% |
67 / 100 |
|
0.00% |
0 / 1 |
47.46 | |||
| isCaptchaSolved | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setCaptchaSolved | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| shouldForceShowCaptcha | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| setForceShowCaptcha | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| editFilterMergedContentHandlerAlreadyInvoked | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setEditFilterMergedContentHandlerInvoked | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| filterLink | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
7.33 | |||
| buildRegexes | |
9.76% |
4 / 41 |
|
0.00% |
0 / 1 |
99.93 | |||
| doConfirmEdit | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
4.77 | |||
| confirmEditMerged | |
70.00% |
14 / 20 |
|
0.00% |
0 / 1 |
5.68 | |||
| getConfirmEditMergedFatalStatusMessageKey | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
| needCreateAccountCaptcha | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| confirmEmailUser | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| isAPICaptchaModule | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| apiGetAllowedParams | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| passCaptchaLimitedFromRequest | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getCaptchaParamsFromRequest | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| passCaptchaLimited | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
| passCaptchaFromRequest | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| passCaptcha | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
5.00 | |||
| log | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| storeCaptcha | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| retrieveCaptcha | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| clearCaptcha | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| loadText | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
| findLinks | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| showHelp | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| createAuthenticationRequest | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| onAuthChangeFormFields | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
| canSkipCaptcha | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| joinURLs | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
8.30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\ConfirmEdit\SimpleCaptcha; |
| 4 | |
| 5 | use MailAddress; |
| 6 | use MediaWiki\Api\ApiBase; |
| 7 | use MediaWiki\Api\ApiEditPage; |
| 8 | use MediaWiki\Auth\AuthenticationRequest; |
| 9 | use MediaWiki\Content\Content; |
| 10 | use MediaWiki\Content\TextContent; |
| 11 | use MediaWiki\Context\IContextSource; |
| 12 | use MediaWiki\Context\RequestContext; |
| 13 | use MediaWiki\EditPage\EditPage; |
| 14 | use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest; |
| 15 | use MediaWiki\Extension\ConfirmEdit\CaptchaTriggers; |
| 16 | use MediaWiki\Extension\ConfirmEdit\Hooks\HookRunner; |
| 17 | use MediaWiki\Extension\ConfirmEdit\Store\CaptchaStore; |
| 18 | use MediaWiki\ExternalLinks\ExternalLinksLookup; |
| 19 | use MediaWiki\ExternalLinks\LinkFilter; |
| 20 | use MediaWiki\HTMLForm\HTMLForm; |
| 21 | use MediaWiki\MediaWikiServices; |
| 22 | use MediaWiki\Message\Message; |
| 23 | use MediaWiki\Output\OutputPage; |
| 24 | use MediaWiki\Page\CacheKeyHelper; |
| 25 | use MediaWiki\Page\WikiPage; |
| 26 | use MediaWiki\Parser\ParserOptions; |
| 27 | use MediaWiki\Registration\ExtensionRegistry; |
| 28 | use MediaWiki\Request\ContentSecurityPolicy; |
| 29 | use MediaWiki\Request\WebRequest; |
| 30 | use MediaWiki\Revision\RevisionAccessException; |
| 31 | use MediaWiki\Revision\SlotRecord; |
| 32 | use MediaWiki\Status\Status; |
| 33 | use MediaWiki\Title\Title; |
| 34 | use MediaWiki\User\User; |
| 35 | use OOUI\FieldLayout; |
| 36 | use OOUI\HiddenInputWidget; |
| 37 | use OOUI\NumberInputWidget; |
| 38 | use UnexpectedValueException; |
| 39 | use Wikimedia\IPUtils; |
| 40 | use Wikimedia\Rdbms\IDBAccessObject; |
| 41 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
| 42 | |
| 43 | /** |
| 44 | * Demo CAPTCHA (not for production usage) and base class for real CAPTCHAs |
| 45 | */ |
| 46 | class SimpleCaptcha { |
| 47 | /** |
| 48 | * The session key that stores the UNIX timestamp when the AbuseFilter |
| 49 | * "showcaptcha" consequence stops applying to the user represented by the session. |
| 50 | * |
| 51 | * This is used to enforce that `::shouldForceShowCaptcha` persists across |
| 52 | * the requests made for a single action. |
| 53 | */ |
| 54 | public const ABUSEFILTER_CAPTCHA_CONSEQUENCE_SESSION_KEY = 'confirmedit-captcha-consequence'; |
| 55 | |
| 56 | /** @var string */ |
| 57 | protected static $messagePrefix = 'captcha'; |
| 58 | |
| 59 | /** @var bool Override to force showing the CAPTCHA to users who don't have "skipcaptcha" right. */ |
| 60 | private bool $forceShowCaptcha = false; |
| 61 | |
| 62 | /** @var bool|null Was the CAPTCHA already passed and if yes, with which result? */ |
| 63 | private ?bool $captchaSolved = null; |
| 64 | |
| 65 | /** @var bool Flag to indicate whether the onEditFilterMergedContent hook was invoked. */ |
| 66 | private bool $editFilterMergedContentHandlerCalled = false; |
| 67 | |
| 68 | /** @var array<string,true> Activated captcha status list for a pages by key */ |
| 69 | private $activatedCaptchas = []; |
| 70 | |
| 71 | /** |
| 72 | * Used to select the right message. |
| 73 | * One of sendmail, createaccount, badlogin, edit, create, addurl. |
| 74 | * @var string |
| 75 | */ |
| 76 | protected $action; |
| 77 | |
| 78 | /** @var string Used in log messages. */ |
| 79 | protected $trigger; |
| 80 | |
| 81 | private array $config = []; |
| 82 | |
| 83 | /** |
| 84 | * @param string $action |
| 85 | */ |
| 86 | public function setAction( $action ) { |
| 87 | $this->action = $action; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * @param string $trigger |
| 92 | */ |
| 93 | public function setTrigger( $trigger ) { |
| 94 | $this->trigger = $trigger; |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Return the error from the last passCaptcha* call. |
| 99 | * Not implemented but needed by some child classes. |
| 100 | * @return mixed |
| 101 | */ |
| 102 | public function getError() { |
| 103 | return null; |
| 104 | } |
| 105 | |
| 106 | public function getName(): string { |
| 107 | return wfMessage( static::$messagePrefix . '-name' )->text(); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Returns an array with 'question' and 'answer' keys. |
| 112 | * Subclasses might use a different structure. |
| 113 | * Since MW 1.27, all subclasses must implement this method. |
| 114 | * @return array |
| 115 | */ |
| 116 | public function getCaptcha() { |
| 117 | $a = random_int( 0, 100 ); |
| 118 | $b = random_int( 0, 10 ); |
| 119 | |
| 120 | /* Minus sign is used in the question. UTF-8, |
| 121 | since the api uses text/plain, not text/html */ |
| 122 | $op = random_int( 0, 1 ) ? '+' : '−'; |
| 123 | |
| 124 | // No space before and after $op, to ensure correct |
| 125 | // directionality. |
| 126 | $test = "$a$op$b"; |
| 127 | $answer = ( $op == '+' ) ? ( $a + $b ) : ( $a - $b ); |
| 128 | return [ 'question' => $test, 'answer' => $answer ]; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Returns a list of pages with a key generated by {@see CacheKeyHelper::getKeyForPage}, that have activated |
| 133 | * captchas. |
| 134 | * |
| 135 | * @return array<string,true> |
| 136 | */ |
| 137 | public function getActivatedCaptchas() { |
| 138 | return $this->activatedCaptchas; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * @param array &$resultArr |
| 143 | */ |
| 144 | protected function addCaptchaAPI( &$resultArr ) { |
| 145 | $captcha = $this->getCaptcha(); |
| 146 | $index = $this->storeCaptcha( $captcha ); |
| 147 | $resultArr['captcha'] = $this->describeCaptchaType( $this->action ); |
| 148 | $resultArr['captcha']['id'] = $index; |
| 149 | $resultArr['captcha']['question'] = $captcha['question']; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Describes the captcha type for API clients. |
| 154 | * @param string|null $action The captcha trigger action |
| 155 | * @return array An array with keys 'type' and 'mime', and possibly other |
| 156 | * implementation-specific |
| 157 | */ |
| 158 | public function describeCaptchaType( ?string $action = null ) { |
| 159 | return [ |
| 160 | 'type' => 'simple', |
| 161 | 'mime' => 'text/plain', |
| 162 | ]; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Insert a captcha prompt into the edit form. |
| 167 | * This sample implementation generates a simple arithmetic operation; |
| 168 | * it would be easy to defeat by machine. |
| 169 | * |
| 170 | * Override this! |
| 171 | * |
| 172 | * It is not guaranteed that the CAPTCHA will load synchronously with the main page |
| 173 | * content. So you cannot rely on registering handlers before the page load. E.g.: |
| 174 | * |
| 175 | * NOT SAFE: $( window ).on( 'load', handler ) |
| 176 | * SAFE: $( handler ) |
| 177 | * |
| 178 | * However, if the HTML is loaded dynamically via AJAX, the following order will |
| 179 | * be used. |
| 180 | * |
| 181 | * headitems => modulestyles + modules => add main HTML to DOM when modulestyles + |
| 182 | * modules are ready. |
| 183 | * |
| 184 | * @param int $tabIndex Tab index to start from |
| 185 | * @param OutputPage|null $out The OutputPage instance where associated with the form the captcha is being added |
| 186 | * to. If this is null, then the OutputPage object from the main request context is used. It is recommended |
| 187 | * to provide this and may be made required in the future. |
| 188 | * |
| 189 | * @return array Associative array with the following keys: |
| 190 | * string html - Main HTML |
| 191 | * array modules (optional) - Array of ResourceLoader module names |
| 192 | * array modulestyles (optional) - Array of ResourceLoader module names to be |
| 193 | * included as style-only modules. |
| 194 | * array headitems (optional) - Head items (see OutputPage::addHeadItems), as a numeric array |
| 195 | * of raw HTML strings. Do not use unless no other option is feasible. |
| 196 | */ |
| 197 | public function getFormInformation( $tabIndex = 1, ?OutputPage $out = null ) { |
| 198 | $captcha = $this->getCaptcha(); |
| 199 | $index = $this->storeCaptcha( $captcha ); |
| 200 | |
| 201 | return [ |
| 202 | 'html' => |
| 203 | new FieldLayout( |
| 204 | new NumberInputWidget( [ |
| 205 | 'name' => 'wpCaptchaWord', |
| 206 | 'classes' => [ 'simplecaptcha-answer' ], |
| 207 | 'id' => 'wpCaptchaWord', |
| 208 | 'autocomplete' => 'off', |
| 209 | // tab in before the edit textarea |
| 210 | 'tabIndex' => $tabIndex |
| 211 | ] ), |
| 212 | [ |
| 213 | 'align' => 'left', |
| 214 | 'label' => $captcha['question'] . ' = ', |
| 215 | 'classes' => [ 'simplecaptcha-field' ], |
| 216 | ] |
| 217 | ) . |
| 218 | new HiddenInputWidget( [ |
| 219 | 'name' => 'wpCaptchaId', |
| 220 | 'id' => 'wpCaptchaId', |
| 221 | 'value' => $index |
| 222 | ] ), |
| 223 | 'modulestyles' => [ |
| 224 | 'ext.confirmEdit.simpleCaptcha' |
| 225 | ] |
| 226 | ]; |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * @return string[] |
| 231 | */ |
| 232 | public static function getCSPUrls() { |
| 233 | return []; |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Adds the necessary CSP policies for the captcha module to work in a CSP enforced |
| 238 | * setup. |
| 239 | * |
| 240 | * @param ContentSecurityPolicy $csp The CSP instance to add the policies to, usually |
| 241 | * obtained from {@link OutputPage::getCSP()} |
| 242 | */ |
| 243 | public static function addCSPSources( ContentSecurityPolicy $csp ) { |
| 244 | foreach ( static::getCSPUrls() as $src ) { |
| 245 | $csp->addScriptSrc( $src ); |
| 246 | $csp->addStyleSrc( $src ); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Uses getFormInformation() to get the CAPTCHA form and adds it to the given |
| 252 | * OutputPage object. |
| 253 | * |
| 254 | * @param OutputPage $out The OutputPage object to which the form should be added |
| 255 | * @param int $tabIndex See self::getFormInformation |
| 256 | */ |
| 257 | public function addFormToOutput( OutputPage $out, $tabIndex = 1 ) { |
| 258 | $this->addFormInformationToOutput( $out, $this->getFormInformation( $tabIndex, $out ) ); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Processes the given $formInformation array and adds the options (see getFormInformation()) |
| 263 | * to the given OutputPage object. |
| 264 | * |
| 265 | * @param OutputPage $out The OutputPage object to which the form should be added |
| 266 | * @param array $formInformation |
| 267 | */ |
| 268 | public function addFormInformationToOutput( OutputPage $out, array $formInformation ) { |
| 269 | static::addCSPSources( $out->getCSP() ); |
| 270 | |
| 271 | if ( !$formInformation ) { |
| 272 | return; |
| 273 | } |
| 274 | if ( isset( $formInformation['html'] ) ) { |
| 275 | $out->addHTML( $formInformation['html'] ); |
| 276 | } |
| 277 | if ( isset( $formInformation['modules'] ) ) { |
| 278 | $out->addModules( $formInformation['modules'] ); |
| 279 | } |
| 280 | if ( isset( $formInformation['modulestyles'] ) ) { |
| 281 | $out->addModuleStyles( $formInformation['modulestyles'] ); |
| 282 | } |
| 283 | if ( isset( $formInformation['headitems'] ) ) { |
| 284 | $out->addHeadItems( $formInformation['headitems'] ); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * @param array $captchaData Data given by getCaptcha |
| 290 | * @param string $id ID given by storeCaptcha |
| 291 | * @return string Description of the captcha. Format is not specified; could be text, HTML, URL... |
| 292 | */ |
| 293 | public function getCaptchaInfo( $captchaData, $id ) { |
| 294 | return array_key_exists( 'question', $captchaData ) ? ( $captchaData['question'] . ' =' ) : ''; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Show the error message for missing or incorrect captcha on EditPage. |
| 299 | * @param EditPage $editPage |
| 300 | * @param OutputPage $out |
| 301 | */ |
| 302 | public function showEditFormFields( EditPage $editPage, OutputPage $out ) { |
| 303 | $out->enableOOUI(); |
| 304 | $page = $editPage->getArticle()->getPage(); |
| 305 | $key = CacheKeyHelper::getKeyForPage( $page ); |
| 306 | if ( !isset( $this->activatedCaptchas[$key] ) ) { |
| 307 | return; |
| 308 | } |
| 309 | |
| 310 | if ( $this->action !== 'edit' ) { |
| 311 | unset( $this->activatedCaptchas[$key] ); |
| 312 | $out->addHTML( $this->getMessage( $this->action )->parseAsBlock() ); |
| 313 | $this->addFormToOutput( $out ); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Insert the captcha prompt into an edit form. |
| 319 | * @param EditPage $editPage |
| 320 | */ |
| 321 | public function editShowCaptcha( $editPage ) { |
| 322 | $context = $editPage->getArticle()->getContext(); |
| 323 | $page = $editPage->getArticle()->getPage(); |
| 324 | $out = $context->getOutput(); |
| 325 | $key = CacheKeyHelper::getKeyForPage( $page ); |
| 326 | if ( isset( $this->activatedCaptchas[$key] ) || |
| 327 | $this->shouldCheck( $page, '', '', $context ) |
| 328 | ) { |
| 329 | $out->addHTML( $this->getMessage( $this->action )->parseAsBlock() ); |
| 330 | $this->addFormToOutput( $out ); |
| 331 | } |
| 332 | unset( $this->activatedCaptchas[$key] ); |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Show a message asking the user to enter a captcha on edit |
| 337 | * The result will be treated as wiki text |
| 338 | * |
| 339 | * @param string $action Action being performed |
| 340 | * @return Message |
| 341 | */ |
| 342 | public function getMessage( $action ) { |
| 343 | // one of captcha-edit, captcha-addurl, captcha-badlogin, captcha-createaccount, |
| 344 | // captcha-create, captcha-sendemail |
| 345 | $msg = wfMessage( static::$messagePrefix . '-' . $action ); |
| 346 | // obtain a more tailored message, if possible, otherwise, fall back to |
| 347 | // the default for edits |
| 348 | return $msg->isDisabled() ? wfMessage( static::$messagePrefix . '-edit' ) : $msg; |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * Inject whazawhoo |
| 353 | * @fixme if multiple thingies insert a header, could break |
| 354 | * @param HTMLForm $form |
| 355 | * @return bool true to keep running callbacks |
| 356 | */ |
| 357 | public function injectEmailUser( HTMLForm $form ) { |
| 358 | $out = $form->getOutput(); |
| 359 | $user = $form->getUser(); |
| 360 | if ( $this->triggersCaptcha( CaptchaTriggers::SENDEMAIL ) ) { |
| 361 | $this->action = 'sendemail'; |
| 362 | if ( $this->canSkipCaptcha( $user ) ) { |
| 363 | return true; |
| 364 | } |
| 365 | $formInformation = $this->getFormInformation( 1, $out ); |
| 366 | $formMetainfo = $formInformation; |
| 367 | unset( $formMetainfo['html'] ); |
| 368 | $this->addFormInformationToOutput( $out, $formMetainfo ); |
| 369 | $form->addFooterHtml( |
| 370 | "<div class='captcha'>" . |
| 371 | $this->getMessage( 'sendemail' )->parseAsBlock() . |
| 372 | $formInformation['html'] . |
| 373 | "</div>\n" ); |
| 374 | } |
| 375 | return true; |
| 376 | } |
| 377 | |
| 378 | /** |
| 379 | * Check if the current IP is allowed to skip solving a captcha. |
| 380 | * This checks the bypass list from two sources. |
| 381 | * 1) From the server-side config array $wgCaptchaBypassIPs |
| 382 | * 2) From the local [[MediaWiki:Captcha-ip-whitelist]] message |
| 383 | * |
| 384 | * @return bool true if the IP can bypass a captcha, false if not |
| 385 | */ |
| 386 | private function canIPBypassCaptcha() { |
| 387 | global $wgCaptchaBypassIPs, $wgRequest; |
| 388 | $ip = $wgRequest->getIP(); |
| 389 | |
| 390 | if ( $wgCaptchaBypassIPs && IPUtils::isInRanges( $ip, $wgCaptchaBypassIPs ) ) { |
| 391 | return true; |
| 392 | } |
| 393 | |
| 394 | $msg = wfMessage( 'captcha-ip-whitelist' )->inContentLanguage(); |
| 395 | if ( !$msg->isDisabled() ) { |
| 396 | $allowedIPs = $this->getWikiIPBypassList( $msg ); |
| 397 | if ( IPUtils::isInRanges( $ip, $allowedIPs ) ) { |
| 398 | return true; |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | return false; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Get the on-wiki IP bypass list stored on a MediaWiki page from cache if possible. |
| 407 | * |
| 408 | * @param Message $msg Message on wiki with IP lists |
| 409 | * @return array Allowed IP addresses or IP ranges, empty array if none |
| 410 | */ |
| 411 | private function getWikiIPBypassList( Message $msg ) { |
| 412 | $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); |
| 413 | $cacheKey = $cache->makeKey( 'confirmedit', 'ipbypasslist' ); |
| 414 | |
| 415 | $cached = $cache->get( $cacheKey ); |
| 416 | if ( $cached !== false ) { |
| 417 | return $cached; |
| 418 | } |
| 419 | |
| 420 | // Could not retrieve from cache, so build the list directly |
| 421 | // from the MediaWiki page |
| 422 | $list = $this->buildValidIPs( |
| 423 | explode( "\n", $msg->plain() ) |
| 424 | ); |
| 425 | // And then store it in cache for one day. |
| 426 | // This cache is cleared on modifications to the wiki page. |
| 427 | // @see MediaWiki\Extension\ConfirmEdit\Hooks::onPageSaveComplete() |
| 428 | $cache->set( $cacheKey, $list, 86400 ); |
| 429 | |
| 430 | return $list; |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * From a list of unvalidated input, get all the valid |
| 435 | * IP addresses and IP ranges from it. |
| 436 | * |
| 437 | * Note that only lines with just the IP address or the IP range is considered |
| 438 | * as valid. Whitespace is allowed, but if there is any other character on |
| 439 | * the line, it's not considered as a valid entry. |
| 440 | * |
| 441 | * @param string[] $input |
| 442 | * @return string[] of valid IP addresses and IP ranges |
| 443 | */ |
| 444 | private function buildValidIPs( array $input ) { |
| 445 | // Remove whitespace and blank lines first |
| 446 | $ips = array_map( 'trim', $input ); |
| 447 | $ips = array_filter( $ips ); |
| 448 | |
| 449 | $validIPs = []; |
| 450 | foreach ( $ips as $ip ) { |
| 451 | if ( IPUtils::isIPAddress( $ip ) ) { |
| 452 | $validIPs[] = $ip; |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | return $validIPs; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Check if the submitted form matches the captcha session data provided |
| 461 | * by the plugin when the form was generated. |
| 462 | * |
| 463 | * Override this! |
| 464 | * |
| 465 | * @param string $answer |
| 466 | * @param array $info |
| 467 | * @return bool |
| 468 | */ |
| 469 | protected function keyMatch( $answer, $info ) { |
| 470 | return $answer == $info['answer']; |
| 471 | } |
| 472 | |
| 473 | // ---------------------------------- |
| 474 | |
| 475 | /** |
| 476 | * Checks, whether the passed action should trigger a CAPTCHA. The optional $title parameter |
| 477 | * will be used to check namespace specific CAPTCHA triggers. |
| 478 | * |
| 479 | * @param string $action The CAPTCHA trigger to check (see CaptchaTriggers for ConfirmEdit |
| 480 | * built-in triggers) |
| 481 | * @param Title|null $title An optional Title object, if the namespace specific triggers |
| 482 | * should be checked, too. |
| 483 | * @return bool True, if the action should trigger a CAPTCHA, false otherwise |
| 484 | */ |
| 485 | public function triggersCaptcha( $action, $title = null ) { |
| 486 | // Captcha was already solved, we don't need to check anything else. |
| 487 | if ( $this->isCaptchaSolved() ) { |
| 488 | return false; |
| 489 | } |
| 490 | |
| 491 | global $wgCaptchaTriggers, $wgCaptchaTriggersOnNamespace; |
| 492 | |
| 493 | $result = false; |
| 494 | $triggers = $wgCaptchaTriggers; |
| 495 | $attributeCaptchaTriggers = ExtensionRegistry::getInstance() |
| 496 | ->getAttribute( CaptchaTriggers::EXT_REG_ATTRIBUTE_NAME ); |
| 497 | if ( is_array( $attributeCaptchaTriggers ) ) { |
| 498 | $triggers += $attributeCaptchaTriggers; |
| 499 | } |
| 500 | |
| 501 | if ( isset( $triggers[$action] ) ) { |
| 502 | $res = $triggers[$action]; |
| 503 | if ( !is_array( $res ) ) { |
| 504 | // Handle old style triggers where it was a boolean |
| 505 | $result = $triggers[$action]; |
| 506 | } else { |
| 507 | $result = $triggers[$action]['trigger'] ?? false; |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | if ( |
| 512 | $title !== null && |
| 513 | isset( $wgCaptchaTriggersOnNamespace[$title->getNamespace()][$action] ) |
| 514 | ) { |
| 515 | $result = $wgCaptchaTriggersOnNamespace[$title->getNamespace()][$action]; |
| 516 | } |
| 517 | |
| 518 | // SimpleCaptcha has been instructed to force showing the CAPTCHA, no need to |
| 519 | // check what other hook implementations think. |
| 520 | if ( $this->shouldForceShowCaptcha() ) { |
| 521 | return true; |
| 522 | } |
| 523 | |
| 524 | $hookRunner = new HookRunner( |
| 525 | MediaWikiServices::getInstance()->getHookContainer() |
| 526 | ); |
| 527 | $hookRunner->onConfirmEditTriggersCaptcha( $action, $title, $result ); |
| 528 | |
| 529 | return $result; |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * @param WikiPage $page |
| 534 | * @param Content|string $content |
| 535 | * @param string $section |
| 536 | * @param IContextSource $context |
| 537 | * @param string|null $oldtext The content of the revision prior to $content When |
| 538 | * null this will be loaded from the database. |
| 539 | * @return bool true if the captcha should run |
| 540 | */ |
| 541 | public function shouldCheck( WikiPage $page, $content, $section, $context, $oldtext = null ) { |
| 542 | if ( !$context instanceof IContextSource ) { |
| 543 | $context = RequestContext::getMain(); |
| 544 | } |
| 545 | |
| 546 | $request = $context->getRequest(); |
| 547 | $user = $context->getUser(); |
| 548 | |
| 549 | if ( $this->canSkipCaptcha( $user ) ) { |
| 550 | return false; |
| 551 | } |
| 552 | |
| 553 | $title = $page->getTitle(); |
| 554 | $this->trigger = ''; |
| 555 | |
| 556 | // If force show captcha is set (e.g., by AbuseFilter), bypass normal trigger checks |
| 557 | if ( $this->shouldForceShowCaptcha() && !$this->isCaptchaSolved() ) { |
| 558 | // Preserve existing action if set, otherwise default to 'edit' |
| 559 | if ( $this->action === null ) { |
| 560 | $this->action = CaptchaTriggers::EDIT; |
| 561 | } |
| 562 | wfDebug( "ConfirmEdit: force showing captcha for {$this->action}...\n" ); |
| 563 | $this->trigger = sprintf( "force show trigger by '%s' at [[%s]] for %s", |
| 564 | $user->getName(), |
| 565 | $title->getPrefixedText(), |
| 566 | $this->action ); |
| 567 | return true; |
| 568 | } |
| 569 | |
| 570 | if ( $content instanceof Content ) { |
| 571 | if ( $content->getModel() == CONTENT_MODEL_WIKITEXT ) { |
| 572 | $newtext = $content->getNativeData(); |
| 573 | } else { |
| 574 | $newtext = null; |
| 575 | } |
| 576 | $isEmpty = $content->isEmpty(); |
| 577 | } else { |
| 578 | $newtext = $content; |
| 579 | $isEmpty = $content === ''; |
| 580 | } |
| 581 | |
| 582 | if ( !$isEmpty && $request->wasPosted() && $this->triggersCaptcha( CaptchaTriggers::ADD_URL, $title ) ) { |
| 583 | // Only check edits that add URLs |
| 584 | if ( $content instanceof Content ) { |
| 585 | // Get links from the database |
| 586 | $oldLinks = ExternalLinksLookup::getExtLinksForPage( |
| 587 | $title->getArticleID(), |
| 588 | MediaWikiServices::getInstance()->getConnectionProvider(), |
| 589 | __METHOD__ |
| 590 | ); |
| 591 | // Share a parse operation with Article::doEdit() |
| 592 | $editInfo = $page->prepareContentForEdit( $content, null, $user ); |
| 593 | if ( $editInfo->output ) { |
| 594 | $newLinks = LinkFilter::getIndexedUrlsNonReversed( |
| 595 | array_keys( $editInfo->output->getExternalLinks() ) |
| 596 | ); |
| 597 | } else { |
| 598 | $newLinks = []; |
| 599 | } |
| 600 | } else { |
| 601 | // Get link changes in the slowest way known to man |
| 602 | $oldtext ??= $this->loadText( $title, $section ); |
| 603 | $oldLinks = $this->findLinks( $title, $oldtext ); |
| 604 | $newLinks = $this->findLinks( $title, $newtext ); |
| 605 | } |
| 606 | |
| 607 | $unknownLinks = array_filter( $newLinks, [ $this, 'filterLink' ] ); |
| 608 | $addedLinks = array_diff( $unknownLinks, $oldLinks ); |
| 609 | $numLinks = count( $addedLinks ); |
| 610 | |
| 611 | if ( $numLinks > 0 ) { |
| 612 | $this->trigger = sprintf( |
| 613 | "URL trigger by '%s' at [[%s]] (%u URLs): %s", |
| 614 | $user->getName(), |
| 615 | $title->getPrefixedText(), |
| 616 | $numLinks, |
| 617 | // T411168 Truncate the message if it contains too many URLs |
| 618 | $this->joinURLs( $addedLinks ) |
| 619 | ); |
| 620 | |
| 621 | $this->action = CaptchaTriggers::ADD_URL; |
| 622 | |
| 623 | // Set instance-specific config from CaptchaTriggers for addurl |
| 624 | // to allow per-trigger configuration (e.g., different sitekeys) |
| 625 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
| 626 | $captchaTriggers = $config->get( 'CaptchaTriggers' ); |
| 627 | if ( is_array( $captchaTriggers[CaptchaTriggers::ADD_URL] ?? null ) ) { |
| 628 | $this->setConfig( $captchaTriggers[CaptchaTriggers::ADD_URL]['config'] ?? [] ); |
| 629 | } else { |
| 630 | $this->setConfig( [] ); |
| 631 | } |
| 632 | |
| 633 | return true; |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | if ( $this->triggersCaptcha( CaptchaTriggers::EDIT, $title ) ) { |
| 638 | // Check on all edits |
| 639 | $this->trigger = sprintf( "edit trigger by '%s' at [[%s]]", |
| 640 | $user->getName(), |
| 641 | $title->getPrefixedText() ); |
| 642 | $this->action = CaptchaTriggers::EDIT; |
| 643 | wfDebug( "ConfirmEdit: checking all edits...\n" ); |
| 644 | return true; |
| 645 | } |
| 646 | |
| 647 | if ( $this->triggersCaptcha( CaptchaTriggers::CREATE, $title ) && !$title->exists() ) { |
| 648 | // Check if creating a page |
| 649 | $this->trigger = sprintf( "Create trigger by '%s' at [[%s]]", |
| 650 | $user->getName(), |
| 651 | $title->getPrefixedText() ); |
| 652 | $this->action = CaptchaTriggers::CREATE; |
| 653 | wfDebug( "ConfirmEdit: checking on page creation...\n" ); |
| 654 | return true; |
| 655 | } |
| 656 | |
| 657 | // The following checks are expensive and should be done only if we can assume that the edit will be saved |
| 658 | if ( !$request->wasPosted() ) { |
| 659 | wfDebug( |
| 660 | "ConfirmEdit: request not posted, assuming that no content will be saved -> no CAPTCHA check" |
| 661 | ); |
| 662 | return false; |
| 663 | } |
| 664 | |
| 665 | global $wgCaptchaRegexes; |
| 666 | if ( $newtext !== null && $wgCaptchaRegexes ) { |
| 667 | if ( !is_array( $wgCaptchaRegexes ) ) { |
| 668 | throw new UnexpectedValueException( |
| 669 | '$wgCaptchaRegexes is required to be an array, ' . get_debug_type( $wgCaptchaRegexes ) . ' given.' |
| 670 | ); |
| 671 | } |
| 672 | // Custom regex checks. Reuse $oldtext if set above. |
| 673 | $oldtext ??= $this->loadText( $title, $section ); |
| 674 | |
| 675 | foreach ( $wgCaptchaRegexes as $regex ) { |
| 676 | $newMatches = []; |
| 677 | if ( preg_match_all( $regex, $newtext, $newMatches ) ) { |
| 678 | $oldMatches = []; |
| 679 | preg_match_all( $regex, $oldtext, $oldMatches ); |
| 680 | |
| 681 | $addedMatches = array_diff( $newMatches[0], $oldMatches[0] ); |
| 682 | |
| 683 | $numHits = count( $addedMatches ); |
| 684 | if ( $numHits > 0 ) { |
| 685 | $this->trigger = sprintf( "%dx %s by %s at [[%s]]: %s", |
| 686 | $numHits, |
| 687 | $regex, |
| 688 | $user->getName(), |
| 689 | $title->getPrefixedText(), |
| 690 | implode( ", ", $addedMatches ) ); |
| 691 | $this->action = CaptchaTriggers::EDIT; |
| 692 | return true; |
| 693 | } |
| 694 | } |
| 695 | } |
| 696 | } |
| 697 | |
| 698 | return false; |
| 699 | } |
| 700 | |
| 701 | public function isCaptchaSolved(): ?bool { |
| 702 | return $this->captchaSolved; |
| 703 | } |
| 704 | |
| 705 | protected function setCaptchaSolved( ?bool $captchaSolved ): void { |
| 706 | $this->captchaSolved = $captchaSolved; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * @return bool True if an override is set to force showing a CAPTCHA |
| 711 | * to the user. Note that users with "skipcaptcha" right may still |
| 712 | * bypass this override. |
| 713 | */ |
| 714 | public function shouldForceShowCaptcha(): bool { |
| 715 | if ( $this->forceShowCaptcha ) { |
| 716 | return $this->forceShowCaptcha; |
| 717 | } |
| 718 | |
| 719 | $expiry = (int)RequestContext::getMain()->getRequest()->getSession()->get( |
| 720 | self::ABUSEFILTER_CAPTCHA_CONSEQUENCE_SESSION_KEY, |
| 721 | 0 |
| 722 | ); |
| 723 | |
| 724 | // Update the local variable in order to avoid further lookups in the |
| 725 | // user session on next calls in case the session value is still valid. |
| 726 | $this->forceShowCaptcha = ( $expiry > ConvertibleTimestamp::time() ); |
| 727 | |
| 728 | return $this->forceShowCaptcha; |
| 729 | } |
| 730 | |
| 731 | /** |
| 732 | * @param bool $forceShowCaptcha True if the caller wants to force showing |
| 733 | * a CAPTCHA to the user. Note that users with "skipcaptcha" right may |
| 734 | * still bypass this override. |
| 735 | * @return void |
| 736 | */ |
| 737 | public function setForceShowCaptcha( bool $forceShowCaptcha ): void { |
| 738 | $this->forceShowCaptcha = $forceShowCaptcha; |
| 739 | |
| 740 | // The flag won't survive a page reload in cases when the user is taken |
| 741 | // back to the edit form with an error message stating that the captcha |
| 742 | // should be solved. Therefore, a session variable is used to make |
| 743 | // ::shouldForceShowCaptcha() return true even if it is called on an |
| 744 | // instance where setForceShowCaptcha() has not been explicitly called |
| 745 | // as part of handling the current request. |
| 746 | // |
| 747 | // Please note that makes the showcaptcha consequence to remain set for |
| 748 | // next requests instead of being set just for the current one. |
| 749 | // |
| 750 | // On the other hand, if the flag is explicitly set back to false, the |
| 751 | // session variable is removed. |
| 752 | $session = RequestContext::getMain()->getRequest()->getSession(); |
| 753 | if ( $forceShowCaptcha ) { |
| 754 | $session->set( |
| 755 | self::ABUSEFILTER_CAPTCHA_CONSEQUENCE_SESSION_KEY, |
| 756 | ConvertibleTimestamp::time() + |
| 757 | MediaWikiServices::getInstance()->getMainConfig()->get( |
| 758 | 'CaptchaAbuseFilterCaptchaConsequenceTTL' |
| 759 | ) |
| 760 | ); |
| 761 | } else { |
| 762 | $session->remove( |
| 763 | self::ABUSEFILTER_CAPTCHA_CONSEQUENCE_SESSION_KEY |
| 764 | ); |
| 765 | } |
| 766 | } |
| 767 | |
| 768 | /** |
| 769 | * @return bool Was the EditFilterMergedContent hook implementation already |
| 770 | * invoked? |
| 771 | */ |
| 772 | public function editFilterMergedContentHandlerAlreadyInvoked(): bool { |
| 773 | return $this->editFilterMergedContentHandlerCalled; |
| 774 | } |
| 775 | |
| 776 | /** |
| 777 | * @return void Set a flag on the class stating that EditFilterMergedContent handler |
| 778 | * was already run. |
| 779 | */ |
| 780 | public function setEditFilterMergedContentHandlerInvoked(): void { |
| 781 | $this->editFilterMergedContentHandlerCalled = true; |
| 782 | } |
| 783 | |
| 784 | /** |
| 785 | * @param array $config The 'config' property from wgCaptchaTriggers |
| 786 | * @return void |
| 787 | */ |
| 788 | public function setConfig( array $config ): void { |
| 789 | $this->config = $config; |
| 790 | } |
| 791 | |
| 792 | /** |
| 793 | * @return array The 'config' property from wgCaptchaTriggers for a given action |
| 794 | */ |
| 795 | public function getConfig(): array { |
| 796 | return $this->config; |
| 797 | } |
| 798 | |
| 799 | /** |
| 800 | * Filter callback function for URL allow-listing |
| 801 | * @param string $url string to check |
| 802 | * @return bool true if unknown, false if allowed |
| 803 | */ |
| 804 | private function filterLink( $url ) { |
| 805 | global $wgCaptchaIgnoredUrls; |
| 806 | static $regexes = null; |
| 807 | |
| 808 | if ( $regexes === null ) { |
| 809 | $source = wfMessage( 'captcha-addurl-whitelist' )->inContentLanguage(); |
| 810 | |
| 811 | $regexes = $source->isDisabled() |
| 812 | ? [] |
| 813 | : $this->buildRegexes( explode( "\n", $source->plain() ) ); |
| 814 | |
| 815 | if ( $wgCaptchaIgnoredUrls !== false ) { |
| 816 | array_unshift( $regexes, $wgCaptchaIgnoredUrls ); |
| 817 | } |
| 818 | } |
| 819 | |
| 820 | foreach ( $regexes as $regex ) { |
| 821 | if ( preg_match( $regex, $url ) ) { |
| 822 | return false; |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | return true; |
| 827 | } |
| 828 | |
| 829 | /** |
| 830 | * Build regex from the list of URLs |
| 831 | * @param string[] $lines string from MediaWiki page |
| 832 | * @return string[] Regexes |
| 833 | * @private |
| 834 | */ |
| 835 | private function buildRegexes( $lines ) { |
| 836 | # Code duplicated from the SpamBlacklist extension (r19197) |
| 837 | # and later modified. |
| 838 | |
| 839 | # Strip comments and whitespace, then remove blanks |
| 840 | $lines = array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) ); |
| 841 | |
| 842 | # No lines, don't make a regex which will match everything |
| 843 | if ( count( $lines ) === 0 ) { |
| 844 | wfDebug( "No lines\n" ); |
| 845 | return []; |
| 846 | } |
| 847 | |
| 848 | # Make regex |
| 849 | # It's faster using the S modifier even though it will usually only be run once |
| 850 | // $regex = 'http://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')'; |
| 851 | // return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Si'; |
| 852 | $regexes = []; |
| 853 | $regexStart = [ |
| 854 | 'normal' => '/^(?:https?:)?\/\/+[a-z0-9_\-.]*(?:', |
| 855 | 'noprotocol' => '/^(?:', |
| 856 | ]; |
| 857 | $regexEnd = [ |
| 858 | 'normal' => ')/Si', |
| 859 | 'noprotocol' => ')/Si', |
| 860 | ]; |
| 861 | $regexMax = 4096; |
| 862 | $build = []; |
| 863 | foreach ( $lines as $line ) { |
| 864 | # Extract flags from the line |
| 865 | $options = []; |
| 866 | if ( preg_match( '/^(.*?)\s*<([^<>]*)>$/', $line, $matches ) ) { |
| 867 | if ( $matches[1] === '' ) { |
| 868 | wfDebug( "Line with empty regex\n" ); |
| 869 | continue; |
| 870 | } |
| 871 | $line = $matches[1]; |
| 872 | $opts = preg_split( '/\s*\|\s*/', trim( $matches[2] ) ); |
| 873 | foreach ( $opts as $opt ) { |
| 874 | $opt = strtolower( $opt ); |
| 875 | if ( $opt == 'noprotocol' ) { |
| 876 | $options['noprotocol'] = true; |
| 877 | } |
| 878 | } |
| 879 | } |
| 880 | |
| 881 | $key = isset( $options['noprotocol'] ) ? 'noprotocol' : 'normal'; |
| 882 | |
| 883 | // FIXME: not very robust size check, but should work. :) |
| 884 | if ( !isset( $build[$key] ) ) { |
| 885 | $build[$key] = $line; |
| 886 | } elseif ( strlen( $build[$key] ) + strlen( $line ) > $regexMax ) { |
| 887 | $regexes[] = $regexStart[$key] . |
| 888 | str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build[$key] ) ) . |
| 889 | $regexEnd[$key]; |
| 890 | $build[$key] = $line; |
| 891 | } else { |
| 892 | $build[$key] .= '|' . $line; |
| 893 | } |
| 894 | } |
| 895 | foreach ( $build as $key => $value ) { |
| 896 | $regexes[] = $regexStart[$key] . |
| 897 | str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $value ) ) . |
| 898 | $regexEnd[$key]; |
| 899 | } |
| 900 | |
| 901 | return $regexes; |
| 902 | } |
| 903 | |
| 904 | /** |
| 905 | * Backend function for confirmEditMerged() |
| 906 | * @param WikiPage $page |
| 907 | * @param Content|string $newtext |
| 908 | * @param string $section |
| 909 | * @param IContextSource $context |
| 910 | * @param User $user |
| 911 | * @return bool false if the CAPTCHA is rejected, true otherwise |
| 912 | */ |
| 913 | private function doConfirmEdit( |
| 914 | WikiPage $page, |
| 915 | $newtext, |
| 916 | $section, |
| 917 | IContextSource $context, |
| 918 | User $user |
| 919 | ) { |
| 920 | global $wgRequest; |
| 921 | $request = $context->getRequest(); |
| 922 | |
| 923 | // FIXME: Stop using wgRequest in other parts of ConfirmEdit so we can |
| 924 | // stop having to duplicate code for it. |
| 925 | if ( $request->getVal( 'captchaid' ) ) { |
| 926 | $request->setVal( 'wpCaptchaId', $request->getVal( 'captchaid' ) ); |
| 927 | $wgRequest->setVal( 'wpCaptchaId', $request->getVal( 'captchaid' ) ); |
| 928 | } |
| 929 | if ( $request->getVal( 'captchaword' ) ) { |
| 930 | $request->setVal( 'wpCaptchaWord', $request->getVal( 'captchaword' ) ); |
| 931 | $wgRequest->setVal( 'wpCaptchaWord', $request->getVal( 'captchaword' ) ); |
| 932 | } |
| 933 | if ( $this->shouldCheck( $page, $newtext, $section, $context ) ) { |
| 934 | return $this->passCaptchaLimitedFromRequest( $wgRequest, $user ); |
| 935 | } |
| 936 | |
| 937 | wfDebug( "ConfirmEdit: no need to show captcha.\n" ); |
| 938 | return true; |
| 939 | } |
| 940 | |
| 941 | /** |
| 942 | * An efficient edit filter callback based on the text after section merging |
| 943 | * @param IContextSource $context |
| 944 | * @param Content $content |
| 945 | * @param Status $status |
| 946 | * @param string $summary |
| 947 | * @param User $user |
| 948 | * @param bool $minorEdit |
| 949 | * @return bool |
| 950 | */ |
| 951 | public function confirmEditMerged( $context, $content, $status, $summary, $user, $minorEdit ) { |
| 952 | $title = $context->getTitle(); |
| 953 | if ( !$title->canExist() ) { |
| 954 | // we check WikiPage only |
| 955 | // try to get an appropriate title for this page |
| 956 | $title = $context->getTitle(); |
| 957 | if ( $title instanceof Title ) { |
| 958 | $title = $title->getFullText(); |
| 959 | } else { |
| 960 | // otherwise it's an unknown page where this function is called from |
| 961 | $title = 'unknown'; |
| 962 | } |
| 963 | // log this error, it could be a problem in another MediaWiki extension, |
| 964 | // edits should always have a WikiPage if they go through EditFilterMergedContent. |
| 965 | wfDebug( __METHOD__ . ': Skipped ConfirmEdit check: No WikiPage for title ' . $title ); |
| 966 | return true; |
| 967 | } |
| 968 | $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); |
| 969 | if ( !$this->doConfirmEdit( $page, $content, '', $context, $user ) ) { |
| 970 | $status->value = EditPage::AS_HOOK_ERROR_EXPECTED; |
| 971 | $status->statusData = []; |
| 972 | $fatalErrorMessage = $this->getConfirmEditMergedFatalStatusMessageKey(); |
| 973 | if ( $fatalErrorMessage ) { |
| 974 | $status->fatal( $fatalErrorMessage ); |
| 975 | } |
| 976 | $this->addCaptchaAPI( $status->statusData ); |
| 977 | $key = CacheKeyHelper::getKeyForPage( $page ); |
| 978 | $this->activatedCaptchas[$key] = true; |
| 979 | return false; |
| 980 | } |
| 981 | return true; |
| 982 | } |
| 983 | |
| 984 | /** |
| 985 | * Returns the message to be added to the {@link Status} provided to {@link self::confirmEditMerged} |
| 986 | * method. |
| 987 | */ |
| 988 | protected function getConfirmEditMergedFatalStatusMessageKey(): null|string { |
| 989 | $message = null; |
| 990 | |
| 991 | // give an error message for the user to know, what goes wrong here. |
| 992 | // this can't be done for addurl trigger, because this requires one "free" save |
| 993 | // for the user, which we don't know when they did it. |
| 994 | if ( $this->action === 'edit' ) { |
| 995 | // The default message is that the user failed a CAPTCHA, so show 'captcha-edit-fail'. |
| 996 | $message = 'captcha-edit-fail'; |
| 997 | if ( $this->shouldForceShowCaptcha() ) { |
| 998 | // If an extension set the forceShowCaptcha property, then it likely means |
| 999 | // that the user already submitted an edit, and so the 'captcha-edit' |
| 1000 | // message is more appropriate. |
| 1001 | $message = 'captcha-edit'; |
| 1002 | [ , $word ] = $this->getCaptchaParamsFromRequest( |
| 1003 | RequestContext::getMain()->getRequest() |
| 1004 | ); |
| 1005 | // But if there's a word supplied in the request, then we should |
| 1006 | // use 'captcha-edit-fail' as it indicates a failed attempt |
| 1007 | // at solving the CAPTCHA by the user. |
| 1008 | if ( $word ) { |
| 1009 | $message = 'captcha-edit-fail'; |
| 1010 | } |
| 1011 | } |
| 1012 | } |
| 1013 | |
| 1014 | return $message; |
| 1015 | } |
| 1016 | |
| 1017 | /** |
| 1018 | * Logic to check if we need to pass a captcha for the current user |
| 1019 | * to create a new account, or not |
| 1020 | * |
| 1021 | * @param User $creatingUser |
| 1022 | * @return bool true to show captcha, false to skip captcha |
| 1023 | */ |
| 1024 | public function needCreateAccountCaptcha( User $creatingUser ) { |
| 1025 | if ( $this->triggersCaptcha( CaptchaTriggers::CREATE_ACCOUNT ) ) { |
| 1026 | return !$this->canSkipCaptcha( $creatingUser ); |
| 1027 | } |
| 1028 | return false; |
| 1029 | } |
| 1030 | |
| 1031 | /** |
| 1032 | * Check the captcha on Special:EmailUser |
| 1033 | * @param MailAddress $from |
| 1034 | * @param MailAddress $to |
| 1035 | * @param string $subject |
| 1036 | * @param string $text |
| 1037 | * @param string &$error |
| 1038 | * @return bool true to continue saving, false to abort and show a captcha form |
| 1039 | */ |
| 1040 | public function confirmEmailUser( $from, $to, $subject, $text, &$error ) { |
| 1041 | global $wgRequest; |
| 1042 | |
| 1043 | $user = RequestContext::getMain()->getUser(); |
| 1044 | if ( $this->triggersCaptcha( CaptchaTriggers::SENDEMAIL ) ) { |
| 1045 | if ( $this->canSkipCaptcha( $user ) ) { |
| 1046 | return true; |
| 1047 | } |
| 1048 | |
| 1049 | if ( defined( 'MW_API' ) ) { |
| 1050 | # API mode |
| 1051 | # Asking for captchas in the API is really silly |
| 1052 | $error = Status::newFatal( 'captcha-disabledinapi' ); |
| 1053 | return false; |
| 1054 | } |
| 1055 | $this->trigger = "{$user->getName()} sending email"; |
| 1056 | if ( !$this->passCaptchaLimitedFromRequest( $wgRequest, $user ) ) { |
| 1057 | $error = Status::newFatal( 'captcha-sendemail-fail' ); |
| 1058 | return false; |
| 1059 | } |
| 1060 | } |
| 1061 | return true; |
| 1062 | } |
| 1063 | |
| 1064 | /** |
| 1065 | * @param ApiBase $module |
| 1066 | * @return bool |
| 1067 | */ |
| 1068 | protected function isAPICaptchaModule( $module ) { |
| 1069 | return $module instanceof ApiEditPage; |
| 1070 | } |
| 1071 | |
| 1072 | /** |
| 1073 | * @param ApiBase $module |
| 1074 | * @param array &$params |
| 1075 | * @param int $flags |
| 1076 | * @return bool |
| 1077 | */ |
| 1078 | public function apiGetAllowedParams( ApiBase $module, &$params, $flags ) { |
| 1079 | if ( $this->isAPICaptchaModule( $module ) ) { |
| 1080 | $params['captchaword'] = [ |
| 1081 | ApiBase::PARAM_HELP_MSG => 'captcha-apihelp-param-captchaword', |
| 1082 | ]; |
| 1083 | $params['captchaid'] = [ |
| 1084 | ApiBase::PARAM_HELP_MSG => 'captcha-apihelp-param-captchaid', |
| 1085 | ]; |
| 1086 | } |
| 1087 | |
| 1088 | return true; |
| 1089 | } |
| 1090 | |
| 1091 | /** |
| 1092 | * Checks if the user reached the number of false CAPTCHAs and give him some vacation |
| 1093 | * or run self::passCaptcha() and clear counter if correct. |
| 1094 | * |
| 1095 | * @param WebRequest $request |
| 1096 | * @param User $user |
| 1097 | * @return bool |
| 1098 | */ |
| 1099 | public function passCaptchaLimitedFromRequest( WebRequest $request, User $user ) { |
| 1100 | [ $index, $word ] = $this->getCaptchaParamsFromRequest( $request ); |
| 1101 | return $this->passCaptchaLimited( $index, $word, $user ); |
| 1102 | } |
| 1103 | |
| 1104 | /** |
| 1105 | * @param WebRequest $request |
| 1106 | * @return string[]|null[] [ captcha ID, captcha solution ] |
| 1107 | */ |
| 1108 | protected function getCaptchaParamsFromRequest( WebRequest $request ) { |
| 1109 | $index = $request->getVal( 'wpCaptchaId' ); |
| 1110 | $word = $request->getVal( 'wpCaptchaWord' ); |
| 1111 | return [ $index, $word ]; |
| 1112 | } |
| 1113 | |
| 1114 | /** |
| 1115 | * Checks, if the user reached the number of false CAPTCHAs and give him some vacation |
| 1116 | * or run self::passCaptcha() and clear counter if correct. |
| 1117 | * |
| 1118 | * @param string|null $index Captcha identifier |
| 1119 | * @param string|null $word Captcha solution |
| 1120 | * @param User $user User for throttling captcha solving attempts |
| 1121 | * @return bool |
| 1122 | * @see self::passCaptcha() |
| 1123 | */ |
| 1124 | public function passCaptchaLimited( $index, $word, User $user ) { |
| 1125 | // don't increase pingLimiter here, just check, if CAPTCHA limit exceeded |
| 1126 | if ( $user->pingLimiter( 'badcaptcha', 0 ) ) { |
| 1127 | // for debugging add a proper error message, the user will just see a false captcha error message |
| 1128 | $this->log( 'User reached RateLimit, preventing action' ); |
| 1129 | return false; |
| 1130 | } |
| 1131 | |
| 1132 | if ( $this->passCaptcha( $index, $word, $user ) ) { |
| 1133 | return true; |
| 1134 | } |
| 1135 | |
| 1136 | // captcha was not solved: increase the limit and return false |
| 1137 | $user->pingLimiter( 'badcaptcha' ); |
| 1138 | return false; |
| 1139 | } |
| 1140 | |
| 1141 | /** |
| 1142 | * Given a required captcha run, test form input for correct |
| 1143 | * input on the open session. |
| 1144 | * @param WebRequest $request |
| 1145 | * @param User $user |
| 1146 | * @return bool if passed, false if failed or new session |
| 1147 | */ |
| 1148 | public function passCaptchaFromRequest( WebRequest $request, User $user ) { |
| 1149 | [ $index, $word ] = $this->getCaptchaParamsFromRequest( $request ); |
| 1150 | return $this->passCaptcha( $index, $word, $user ); |
| 1151 | } |
| 1152 | |
| 1153 | /** |
| 1154 | * Given a required captcha run, test form input for correct |
| 1155 | * input on the open session. |
| 1156 | * @param string|null $index Captcha identifier |
| 1157 | * @param string|null $word Captcha solution |
| 1158 | * @param User $user |
| 1159 | * @return bool if passed, false if failed or new session |
| 1160 | */ |
| 1161 | protected function passCaptcha( $index, $word, $user ) { |
| 1162 | // Don't check the same CAPTCHA twice in one session, |
| 1163 | // if the CAPTCHA was already checked - Bug T94276 |
| 1164 | if ( $this->isCaptchaSolved() !== null ) { |
| 1165 | return (bool)$this->isCaptchaSolved(); |
| 1166 | } |
| 1167 | |
| 1168 | if ( $index === null ) { |
| 1169 | $this->log( "new captcha session" ); |
| 1170 | // If no captcha ID was passed, we need to start a new session (T384858). |
| 1171 | return false; |
| 1172 | } |
| 1173 | |
| 1174 | $info = $this->retrieveCaptcha( $index ); |
| 1175 | if ( $info ) { |
| 1176 | if ( $this->keyMatch( $word, $info ) ) { |
| 1177 | $this->log( "passed" ); |
| 1178 | $this->clearCaptcha( $index ); |
| 1179 | $this->setCaptchaSolved( true ); |
| 1180 | return true; |
| 1181 | } else { |
| 1182 | $this->clearCaptcha( $index ); |
| 1183 | $this->log( "bad form input" ); |
| 1184 | $this->setCaptchaSolved( false ); |
| 1185 | return false; |
| 1186 | } |
| 1187 | } else { |
| 1188 | $this->log( "new captcha session" ); |
| 1189 | return false; |
| 1190 | } |
| 1191 | } |
| 1192 | |
| 1193 | /** |
| 1194 | * Log the status and any triggering info for debugging or statistics |
| 1195 | * @param string $message |
| 1196 | */ |
| 1197 | protected function log( $message ) { |
| 1198 | wfDebugLog( |
| 1199 | 'captcha', |
| 1200 | 'ConfirmEdit: ' . $message . '; {trigger}', |
| 1201 | 'all', |
| 1202 | [ 'trigger' => $this->trigger ] |
| 1203 | ); |
| 1204 | } |
| 1205 | |
| 1206 | /** |
| 1207 | * Generate a captcha session ID and save the info in PHP's session storage. |
| 1208 | * (Requires the user to have cookies enabled to get through the captcha.) |
| 1209 | * |
| 1210 | * A random ID is used so legit users can make edits in multiple tabs or |
| 1211 | * windows without being unnecessarily hobbled by a serial order requirement. |
| 1212 | * Pass the returned id value into the edit form as wpCaptchaId. |
| 1213 | * |
| 1214 | * @param array $info data to store |
| 1215 | * @return string captcha ID key |
| 1216 | */ |
| 1217 | public function storeCaptcha( $info ) { |
| 1218 | if ( !isset( $info['index'] ) ) { |
| 1219 | // Assign random index if we're not updating |
| 1220 | $info['index'] = (string)random_int( 0, PHP_INT_MAX ); |
| 1221 | } |
| 1222 | CaptchaStore::get()->store( $info['index'], $info ); |
| 1223 | return $info['index']; |
| 1224 | } |
| 1225 | |
| 1226 | /** |
| 1227 | * Fetch this session's captcha info. |
| 1228 | * @param string $index |
| 1229 | * @return array|false array of info, or false if missing |
| 1230 | */ |
| 1231 | public function retrieveCaptcha( $index ) { |
| 1232 | return CaptchaStore::get()->retrieve( $index ); |
| 1233 | } |
| 1234 | |
| 1235 | /** |
| 1236 | * Clear out existing captcha info from the session, to ensure |
| 1237 | * it can't be reused. |
| 1238 | * @param string $index |
| 1239 | */ |
| 1240 | public function clearCaptcha( $index ) { |
| 1241 | CaptchaStore::get()->clear( $index ); |
| 1242 | } |
| 1243 | |
| 1244 | /** |
| 1245 | * Retrieve the current version of the page or section being edited... |
| 1246 | * @param Title $title |
| 1247 | * @param string $section |
| 1248 | * @param int $flags Flags for Revision loading methods |
| 1249 | * @return string |
| 1250 | * @private |
| 1251 | */ |
| 1252 | private function loadText( $title, $section, $flags = IDBAccessObject::READ_LATEST ) { |
| 1253 | $revRecord = MediaWikiServices::getInstance() |
| 1254 | ->getRevisionLookup() |
| 1255 | ->getRevisionByTitle( $title, 0, $flags ); |
| 1256 | |
| 1257 | if ( $revRecord === null ) { |
| 1258 | return ""; |
| 1259 | } |
| 1260 | |
| 1261 | try { |
| 1262 | $content = $revRecord->getContent( SlotRecord::MAIN ); |
| 1263 | } catch ( RevisionAccessException ) { |
| 1264 | return ''; |
| 1265 | } |
| 1266 | |
| 1267 | $text = ( $content instanceof TextContent ) ? $content->getText() : null; |
| 1268 | if ( $section !== '' ) { |
| 1269 | return MediaWikiServices::getInstance()->getParser() |
| 1270 | ->getSection( $text, $section ); |
| 1271 | } |
| 1272 | |
| 1273 | return $text; |
| 1274 | } |
| 1275 | |
| 1276 | /** |
| 1277 | * Extract a list of all recognized HTTP links in the text. |
| 1278 | * @param Title $title |
| 1279 | * @param string $text |
| 1280 | * @return string[] |
| 1281 | */ |
| 1282 | private function findLinks( $title, $text ) { |
| 1283 | $parser = MediaWikiServices::getInstance()->getParser(); |
| 1284 | $user = $parser->getUserIdentity(); |
| 1285 | $options = new ParserOptions( $user ); |
| 1286 | $text = $parser->preSaveTransform( $text, $title, $user, $options ); |
| 1287 | $out = $parser->parse( $text, $title, $options ); |
| 1288 | |
| 1289 | return array_keys( $out->getExternalLinks() ); |
| 1290 | } |
| 1291 | |
| 1292 | /** |
| 1293 | * Show a page explaining what this wacky thing is. |
| 1294 | * |
| 1295 | * @param OutputPage $out The OutputPage to add the help text to |
| 1296 | */ |
| 1297 | public function showHelp( OutputPage $out ) { |
| 1298 | $msg = wfMessage( static::$messagePrefix . 'help-text' ); |
| 1299 | if ( $msg->isDisabled() ) { |
| 1300 | // Fallback to captchahelp-text |
| 1301 | $msg = wfMessage( self::$messagePrefix . 'help-text' ); |
| 1302 | } |
| 1303 | |
| 1304 | $out->addWikiMsg( $msg ); |
| 1305 | } |
| 1306 | |
| 1307 | /** |
| 1308 | * @return CaptchaAuthenticationRequest |
| 1309 | */ |
| 1310 | public function createAuthenticationRequest() { |
| 1311 | $captchaData = $this->getCaptcha(); |
| 1312 | $id = $this->storeCaptcha( $captchaData ); |
| 1313 | return new CaptchaAuthenticationRequest( $id, $captchaData ); |
| 1314 | } |
| 1315 | |
| 1316 | /** |
| 1317 | * Modify the appearance of the captcha field |
| 1318 | * @param AuthenticationRequest[] $requests |
| 1319 | * @param array $fieldInfo Field description as given by AuthenticationRequest::mergeFieldInfo |
| 1320 | * @param array &$formDescriptor A form descriptor suitable for the HTMLForm constructor |
| 1321 | * @param string $action One of the AuthManager::ACTION_* constants |
| 1322 | */ |
| 1323 | public function onAuthChangeFormFields( |
| 1324 | array $requests, array $fieldInfo, array &$formDescriptor, $action |
| 1325 | ) { |
| 1326 | /** @var CaptchaAuthenticationRequest $req */ |
| 1327 | $req = AuthenticationRequest::getRequestByClass( |
| 1328 | $requests, |
| 1329 | CaptchaAuthenticationRequest::class, |
| 1330 | true |
| 1331 | ); |
| 1332 | if ( !$req ) { |
| 1333 | return; |
| 1334 | } |
| 1335 | |
| 1336 | $formDescriptor['captchaWord'] = [ |
| 1337 | 'label-message' => null, |
| 1338 | 'autocomplete' => false, |
| 1339 | 'persistent' => false, |
| 1340 | 'required' => true, |
| 1341 | ] + $formDescriptor['captchaWord']; |
| 1342 | } |
| 1343 | |
| 1344 | /** |
| 1345 | * Check whether the user provided / IP making the request is allowed to skip captchas |
| 1346 | */ |
| 1347 | public function canSkipCaptcha( User $user ): bool { |
| 1348 | $result = false; |
| 1349 | if ( $user->isAllowed( 'skipcaptcha' ) ) { |
| 1350 | wfDebug( "ConfirmEdit: user group allows skipping captcha\n" ); |
| 1351 | $result = true; |
| 1352 | } |
| 1353 | if ( $user->isSystemUser() ) { |
| 1354 | wfDebug( "ConfirmEdit: system user skips captcha\n" ); |
| 1355 | $result = true; |
| 1356 | } |
| 1357 | if ( $this->canIPBypassCaptcha() ) { |
| 1358 | wfDebug( "ConfirmEdit: user IP can bypass captcha\n" ); |
| 1359 | $result = true; |
| 1360 | } |
| 1361 | |
| 1362 | $hookRunner = new HookRunner( |
| 1363 | MediaWikiServices::getInstance()->getHookContainer() |
| 1364 | ); |
| 1365 | $hookRunner->onConfirmEditCanUserSkipCaptcha( $user, $result ); |
| 1366 | |
| 1367 | return $result; |
| 1368 | } |
| 1369 | |
| 1370 | /** |
| 1371 | * Given a list of URLs, returns them concatenated (separated by commas). |
| 1372 | * |
| 1373 | * In case the string resulting from concatenating all of them exceeds a |
| 1374 | * fixed max size of 4 kB, the last URLs are replaced by "...". However, if |
| 1375 | * no URL is shorter than 4 kB, the returned value will include the shortest |
| 1376 | * URL plus a ", ..." postfix indicating there are URLs that were omitted. |
| 1377 | * |
| 1378 | * @param string[] $urls URLs to be concatenated |
| 1379 | * @return string |
| 1380 | */ |
| 1381 | private function joinURLs( array $urls ): string { |
| 1382 | if ( count( $urls ) === 0 ) { |
| 1383 | return ''; |
| 1384 | } |
| 1385 | |
| 1386 | usort( |
| 1387 | $urls, |
| 1388 | static fn ( $a, $b ) => strlen( $a ) <=> strlen( $b ) |
| 1389 | ); |
| 1390 | |
| 1391 | $urlString = ''; |
| 1392 | |
| 1393 | foreach ( $urls as $link ) { |
| 1394 | if ( strlen( $link ) > 4090 ) { |
| 1395 | // URLs from this one onward cannot fit individually within |
| 1396 | // the size limit. |
| 1397 | break; |
| 1398 | } |
| 1399 | |
| 1400 | $candidate = ( $urlString === '' ? $link : "{$urlString}, {$link}" ); |
| 1401 | |
| 1402 | // Ensure the list of URLs remains below 4kB. To do so, compare |
| 1403 | // against 4090 to take into account the length of the postfix |
| 1404 | // appended (5 chars) in case $candidate is too long. |
| 1405 | if ( strlen( $candidate ) > 4090 ) { |
| 1406 | $urlString .= ', ...'; |
| 1407 | break; |
| 1408 | } |
| 1409 | |
| 1410 | $urlString = $candidate; |
| 1411 | } |
| 1412 | |
| 1413 | if ( $urlString === '' ) { |
| 1414 | // All URLs were > 4090 bytes, return the shortest one anyway |
| 1415 | return ( $urls[0] ?? '' ) . ( count( $urls ) > 1 ? ', ...' : '' ); |
| 1416 | } |
| 1417 | |
| 1418 | return $urlString; |
| 1419 | } |
| 1420 | } |