4 function __construct() {
5 global $wgCaptchaStorageClass;
6 $this->storage = new $wgCaptchaStorageClass;
9 function getCaptcha() {
10 $a = mt_rand( 0, 100 );
11 $b = mt_rand( 0, 10 );
13 /* Minus sign is used in the question. UTF-8,
14 since the api uses text/plain, not text/html */
15 $op = mt_rand( 0, 1 ) ? '+' : '−';
18 $answer = ( $op == '+' ) ? ( $a + $b ) : ( $a - $b );
19 return array( 'question' => $test, 'answer' => $answer );
22 function addCaptchaAPI( &$resultArr ) {
23 $captcha = $this->getCaptcha();
24 $index = $this->storeCaptcha( $captcha );
25 $resultArr['captcha']['type'] = 'simple';
26 $resultArr['captcha']['mime'] = 'text/plain';
27 $resultArr['captcha']['id'] = $index;
28 $resultArr['captcha']['question'] = $captcha['question'];
32 * Insert a captcha prompt into the edit form.
33 * This sample implementation generates a simple arithmetic operation;
34 * it would be easy to defeat by machine.
41 $captcha = $this->getCaptcha();
42 $index = $this->storeCaptcha( $captcha );
44 return "<p><label for=\"wpCaptchaWord\">{$captcha['question']}</label> = " .
45 Xml::element( 'input', array(
46 'name' => 'wpCaptchaWord',
47 'id' => 'wpCaptchaWord',
48 'tabindex' => 1 ) ) . // tab in before the edit textarea
50 Xml::element( 'input', array(
52 'name' => 'wpCaptchaId',
53 'id' => 'wpCaptchaId',
54 'value' => $index ) );
58 * Insert the captcha prompt into an edit form.
59 * @param OutputPage $out
61 function editCallback( &$out ) {
62 $out->addWikiText( $this->getMessage( $this->action ) );
63 $out->addHTML( $this->getForm() );
67 * Show a message asking the user to enter a captcha on edit
68 * The result will be treated as wiki text
70 * @param $action Action being performed
73 function getMessage( $action ) {
74 $name = 'captcha-' . $action;
75 $text = wfMsg( $name );
76 # Obtain a more tailored message, if possible, otherwise, fall back to
77 # the default for edits
78 return wfEmptyMsg( $name, $text ) ? wfMsg( 'captcha-edit' ) : $text;
83 * @fixme if multiple thingies insert a header, could break
85 * @return bool true to keep running callbacks
87 function injectEmailUser( &$form ) {
88 global $wgCaptchaTriggers, $wgOut, $wgUser;
89 if ( $wgCaptchaTriggers['sendemail'] ) {
90 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
91 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
95 "<div class='captcha'>" .
96 $wgOut->parse( $this->getMessage( 'sendemail' ) ) .
105 * @fixme if multiple thingies insert a header, could break
106 * @param SimpleTemplate $template
107 * @return bool true to keep running callbacks
109 function injectUserCreate( &$template ) {
110 global $wgCaptchaTriggers, $wgOut, $wgUser;
111 if ( $wgCaptchaTriggers['createaccount'] ) {
112 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
113 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
116 $template->set( 'header',
117 "<div class='captcha'>" .
118 $wgOut->parse( $this->getMessage( 'createaccount' ) ) .
126 * Inject a captcha into the user login form after a failed
127 * password attempt as a speedbump for mass attacks.
128 * @fixme if multiple thingies insert a header, could break
129 * @param SimpleTemplate $template
130 * @return bool true to keep running callbacks
132 function injectUserLogin( &$template ) {
133 if ( $this->isBadLoginTriggered() ) {
135 $template->set( 'header',
136 "<div class='captcha'>" .
137 $wgOut->parse( $this->getMessage( 'badlogin' ) ) .
145 * When a bad login attempt is made, increment an expiring counter
146 * in the memcache cloud. Later checks for this may trigger a
147 * captcha display to prevent too many hits from the same place.
149 * @param string $password
150 * @param int $retval authentication return value
151 * @return bool true to keep running callbacks
153 function triggerUserLogin( $user, $password, $retval ) {
154 global $wgCaptchaTriggers, $wgCaptchaBadLoginExpiration, $wgMemc;
155 if ( $retval == LoginForm::WRONG_PASS && $wgCaptchaTriggers['badlogin'] ) {
156 $key = $this->badLoginKey();
157 $count = $wgMemc->get( $key );
159 $wgMemc->add( $key, 0, $wgCaptchaBadLoginExpiration );
161 $count = $wgMemc->incr( $key );
167 * Check if a bad login has already been registered for this
168 * IP address. If so, require a captcha.
172 function isBadLoginTriggered() {
173 global $wgMemc, $wgCaptchaBadLoginAttempts;
174 return intval( $wgMemc->get( $this->badLoginKey() ) ) >= $wgCaptchaBadLoginAttempts;
178 * Check if the IP is allowed to skip captchas
180 function isIPWhitelisted() {
181 global $wgCaptchaWhitelistIP;
182 if ( $wgCaptchaWhitelistIP ) {
184 foreach ( $wgCaptchaWhitelistIP as $range ) {
185 if ( IP::isInRange( $ip, $range ) ) {
194 * Internal cache key for badlogin checks.
198 function badLoginKey() {
199 return wfMemcKey( 'captcha', 'badlogin', 'ip', wfGetIP() );
203 * Check if the submitted form matches the captcha session data provided
204 * by the plugin when the form was generated.
208 * @param string $answer
212 function keyMatch( $answer, $info ) {
213 return $answer == $info['answer'];
216 // ----------------------------------
219 * @param EditPage $editPage
220 * @param string $action (edit/create/addurl...)
221 * @return bool true if action triggers captcha on editPage's namespace
223 function captchaTriggers( &$editPage, $action ) {
224 global $wgCaptchaTriggers, $wgCaptchaTriggersOnNamespace;
225 // Special config for this NS?
226 if ( isset( $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action] ) )
227 return $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action];
229 return ( !empty( $wgCaptchaTriggers[$action] ) ); // Default
233 * @param EditPage $editPage
234 * @param string $newtext
235 * @param string $section
236 * @return bool true if the captcha should run
238 function shouldCheck( &$editPage, $newtext, $section, $merged = false ) {
240 $title = $editPage->mArticle->getTitle();
243 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
244 wfDebug( "ConfirmEdit: user group allows skipping captcha\n" );
247 if ( $this->isIPWhitelisted() )
251 global $wgEmailAuthentication, $ceAllowConfirmedEmail;
252 if ( $wgEmailAuthentication && $ceAllowConfirmedEmail &&
253 $wgUser->isEmailConfirmed() ) {
254 wfDebug( "ConfirmEdit: user has confirmed mail, skipping captcha\n" );
258 if ( $this->captchaTriggers( $editPage, 'edit' ) ) {
259 // Check on all edits
261 $this->trigger = sprintf( "edit trigger by '%s' at [[%s]]",
263 $title->getPrefixedText() );
264 $this->action = 'edit';
265 wfDebug( "ConfirmEdit: checking all edits...\n" );
269 if ( $this->captchaTriggers( $editPage, 'create' ) && !$editPage->mTitle->exists() ) {
270 // Check if creating a page
272 $this->trigger = sprintf( "Create trigger by '%s' at [[%s]]",
274 $title->getPrefixedText() );
275 $this->action = 'create';
276 wfDebug( "ConfirmEdit: checking on page creation...\n" );
280 if ( $this->captchaTriggers( $editPage, 'addurl' ) ) {
281 // Only check edits that add URLs
283 // Get links from the database
284 $oldLinks = $this->getLinksFromTracker( $title );
285 // Share a parse operation with Article::doEdit()
286 $editInfo = $editPage->mArticle->prepareTextForEdit( $newtext );
287 $newLinks = array_keys( $editInfo->output->getExternalLinks() );
289 // Get link changes in the slowest way known to man
290 $oldtext = $this->loadText( $editPage, $section );
291 $oldLinks = $this->findLinks( $editPage, $oldtext );
292 $newLinks = $this->findLinks( $editPage, $newtext );
295 $unknownLinks = array_filter( $newLinks, array( &$this, 'filterLink' ) );
296 $addedLinks = array_diff( $unknownLinks, $oldLinks );
297 $numLinks = count( $addedLinks );
299 if ( $numLinks > 0 ) {
301 $this->trigger = sprintf( "%dx url trigger by '%s' at [[%s]]: %s",
304 $title->getPrefixedText(),
305 implode( ", ", $addedLinks ) );
306 $this->action = 'addurl';
311 global $wgCaptchaRegexes;
312 if ( $wgCaptchaRegexes ) {
313 // Custom regex checks
314 $oldtext = $this->loadText( $editPage, $section );
316 foreach ( $wgCaptchaRegexes as $regex ) {
317 $newMatches = array();
318 if ( preg_match_all( $regex, $newtext, $newMatches ) ) {
319 $oldMatches = array();
320 preg_match_all( $regex, $oldtext, $oldMatches );
322 $addedMatches = array_diff( $newMatches[0], $oldMatches[0] );
324 $numHits = count( $addedMatches );
325 if ( $numHits > 0 ) {
327 $this->trigger = sprintf( "%dx %s at [[%s]]: %s",
331 $title->getPrefixedText(),
332 implode( ", ", $addedMatches ) );
333 $this->action = 'edit';
344 * Filter callback function for URL whitelisting
345 * @param string url to check
346 * @return bool true if unknown, false if whitelisted
349 function filterLink( $url ) {
350 global $wgCaptchaWhitelist;
351 $source = wfMsgForContent( 'captcha-addurl-whitelist' );
353 $whitelist = wfEmptyMsg( 'captcha-addurl-whitelist', $source )
355 : $this->buildRegexes( explode( "\n", $source ) );
357 $cwl = $wgCaptchaWhitelist !== false ? preg_match( $wgCaptchaWhitelist, $url ) : false;
358 $wl = $whitelist !== false ? preg_match( $whitelist, $url ) : false;
360 return !( $cwl || $wl );
364 * Build regex from whitelist
365 * @param string lines from [[MediaWiki:Captcha-addurl-whitelist]]
366 * @return string Regex or bool false if whitelist is empty
369 function buildRegexes( $lines ) {
370 # Code duplicated from the SpamBlacklist extension (r19197)
372 # Strip comments and whitespace, then remove blanks
373 $lines = array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) );
375 # No lines, don't make a regex which will match everything
376 if ( count( $lines ) == 0 ) {
377 wfDebug( "No lines\n" );
381 # It's faster using the S modifier even though it will usually only be run once
382 // $regex = 'http://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')';
383 // return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Si';
385 $regexStart = '/^https?:\/\/+[a-z0-9_\-.]*(';
389 foreach ( $lines as $line ) {
390 // FIXME: not very robust size check, but should work. :)
391 if ( $build === false ) {
393 } elseif ( strlen( $build ) + strlen( $line ) > $regexMax ) {
394 $regexes .= $regexStart .
395 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
399 $build .= '|' . $line;
402 if ( $build !== false ) {
403 $regexes .= $regexStart .
404 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
412 * Load external links from the externallinks table
414 function getLinksFromTracker( $title ) {
415 $dbr = wfGetDB( DB_SLAVE );
416 $id = $title->getArticleId(); // should be zero queries
417 $res = $dbr->select( 'externallinks', array( 'el_to' ),
418 array( 'el_from' => $id ), __METHOD__ );
420 foreach ( $res as $row ) {
421 $links[] = $row->el_to;
427 * Backend function for confirmEdit() and confirmEditAPI()
428 * @return bool false if the CAPTCHA is rejected, true otherwise
430 private function doConfirmEdit( $editPage, $newtext, $section, $merged = false ) {
431 if ( $this->shouldCheck( $editPage, $newtext, $section, $merged ) ) {
432 if ( $this->passCaptcha() ) {
438 wfDebug( "ConfirmEdit: no need to show captcha.\n" );
444 * The main callback run on edit attempts.
445 * @param EditPage $editPage
446 * @param string $newtext
447 * @param string $section
448 * @param bool $merged
449 * @return bool true to continue saving, false to abort and show a captcha form
451 function confirmEdit( $editPage, $newtext, $section, $merged = false ) {
452 if ( defined( 'MW_API' ) ) {
454 # The CAPTCHA was already checked and approved
457 if ( !$this->doConfirmEdit( $editPage, $newtext, $section, $merged ) ) {
458 $editPage->showEditForm( array( &$this, 'editCallback' ) );
465 * A more efficient edit filter callback based on the text after section merging
466 * @param EditPage $editPage
467 * @param string $newtext
469 function confirmEditMerged( $editPage, $newtext ) {
470 return $this->confirmEdit( $editPage, $newtext, false, true );
474 function confirmEditAPI( $editPage, $newtext, &$resultArr ) {
475 if ( !$this->doConfirmEdit( $editPage, $newtext, false, false ) ) {
476 $this->addCaptchaAPI( $resultArr );
483 * Hook for user creation form submissions.
485 * @param string $message
486 * @return bool true to continue, false to abort user creation
488 function confirmUserCreate( $u, &$message ) {
489 global $wgCaptchaTriggers, $wgUser;
490 if ( $wgCaptchaTriggers['createaccount'] ) {
491 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
492 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
495 if ( $this->isIPWhitelisted() )
498 $this->trigger = "new account '" . $u->getName() . "'";
499 if ( !$this->passCaptcha() ) {
500 $message = wfMsg( 'captcha-createaccount-fail' );
508 * Hook for user login form submissions.
510 * @param string $message
511 * @return bool true to continue, false to abort user creation
513 function confirmUserLogin( $u, $pass, &$retval ) {
514 if ( $this->isBadLoginTriggered() ) {
515 if ( $this->isIPWhitelisted() )
518 $this->trigger = "post-badlogin login '" . $u->getName() . "'";
519 if ( !$this->passCaptcha() ) {
520 // Emulate a bad-password return to confuse the shit out of attackers
521 $retval = LoginForm::WRONG_PASS;
529 * Check the captcha on Special:EmailUser
530 * @param $from MailAddress
531 * @param $to MailAddress
532 * @param $subject String
533 * @param $text String
534 * @param $error String reference
535 * @return Bool true to continue saving, false to abort and show a captcha form
537 function confirmEmailUser( $from, $to, $subject, $text, &$error ) {
538 global $wgCaptchaTriggers, $wgUser;
539 if ( $wgCaptchaTriggers['sendemail'] ) {
540 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
541 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
544 if ( $this->isIPWhitelisted() )
547 if ( defined( 'MW_API' ) ) {
549 # Asking for captchas in the API is really silly
550 $error = wfMsg( 'captcha-disabledinapi' );
553 $this->trigger = "{$wgUser->getName()} sending email";
554 if ( !$this->passCaptcha() ) {
555 $error = wfMsg( 'captcha-sendemail-fail' );
563 * Given a required captcha run, test form input for correct
564 * input on the open session.
565 * @return bool if passed, false if failed or new session
567 function passCaptcha() {
568 $info = $this->retrieveCaptcha();
571 if ( $this->keyMatch( $wgRequest->getVal( 'wpCaptchaWord' ), $info ) ) {
572 $this->log( "passed" );
573 $this->clearCaptcha( $info );
576 $this->clearCaptcha( $info );
577 $this->log( "bad form input" );
581 $this->log( "new captcha session" );
587 * Log the status and any triggering info for debugging or statistics
588 * @param string $message
590 function log( $message ) {
591 wfDebugLog( 'captcha', 'ConfirmEdit: ' . $message . '; ' . $this->trigger );
595 * Generate a captcha session ID and save the info in PHP's session storage.
596 * (Requires the user to have cookies enabled to get through the captcha.)
598 * A random ID is used so legit users can make edits in multiple tabs or
599 * windows without being unnecessarily hobbled by a serial order requirement.
600 * Pass the returned id value into the edit form as wpCaptchaId.
602 * @param array $info data to store
603 * @return string captcha ID key
605 function storeCaptcha( $info ) {
606 if ( !isset( $info['index'] ) ) {
607 // Assign random index if we're not udpating
608 $info['index'] = strval( mt_rand() );
610 $this->storage->store( $info['index'], $info );
611 return $info['index'];
615 * Fetch this session's captcha info.
616 * @return mixed array of info, or false if missing
618 function retrieveCaptcha() {
620 $index = $wgRequest->getVal( 'wpCaptchaId' );
621 return $this->storage->retrieve( $index );
625 * Clear out existing captcha info from the session, to ensure
626 * it can't be reused.
628 function clearCaptcha( $info ) {
629 $this->storage->clear( $info['index'] );
633 * Retrieve the current version of the page or section being edited...
634 * @param EditPage $editPage
635 * @param string $section
639 function loadText( $editPage, $section ) {
640 $rev = Revision::newFromTitle( $editPage->mTitle );
641 if ( is_null( $rev ) ) {
644 $text = $rev->getText();
645 if ( $section != '' ) {
647 return $wgParser->getSection( $text, $section );
655 * Extract a list of all recognized HTTP links in the text.
656 * @param string $text
657 * @return array of strings
659 function findLinks( &$editpage, $text ) {
660 global $wgParser, $wgUser;
662 $options = new ParserOptions();
663 $text = $wgParser->preSaveTransform( $text, $editpage->mTitle, $wgUser, $options );
664 $out = $wgParser->parse( $text, $editpage->mTitle, $options );
666 return array_keys( $out->getExternalLinks() );
670 * Show a page explaining what this wacky thing is.
672 function showHelp() {
674 $wgOut->setPageTitle( wfMsg( 'captchahelp-title' ) );
675 $wgOut->addWikiText( wfMsg( 'captchahelp-text' ) );
676 if ( $this->storage->cookiesNeeded() ) {
677 $wgOut->addWikiText( wfMsg( 'captchahelp-cookies-needed' ) );