3 class ConfirmEditHooks {
4 static function getInstance() {
5 global $wgCaptcha, $wgCaptchaClass, $wgExtensionMessagesFiles;
9 wfLoadExtensionMessages( 'ConfirmEdit' );
10 if ( isset( $wgExtensionMessagesFiles[$wgCaptchaClass] ) ) {
11 wfLoadExtensionMessages( $wgCaptchaClass );
13 $wgCaptcha = new $wgCaptchaClass;
18 static function confirmEdit( $editPage, $newtext, $section ) {
19 return self::getInstance()->confirmEdit( $editPage, $newtext, $section );
22 static function confirmEditMerged( $editPage, $newtext ) {
23 return self::getInstance()->confirmEditMerged( $editPage, $newtext );
26 static function confirmEditAPI( $editPage, $newtext, &$resultArr ) {
27 return self::getInstance()->confirmEditAPI( $editPage, $newtext, $resultArr );
30 static function injectUserCreate( &$template ) {
31 return self::getInstance()->injectUserCreate( $template );
34 static function confirmUserCreate( $u, &$message ) {
35 return self::getInstance()->confirmUserCreate( $u, $message );
38 static function triggerUserLogin( $user, $password, $retval ) {
39 return self::getInstance()->triggerUserLogin( $user, $password, $retval );
42 static function injectUserLogin( &$template ) {
43 return self::getInstance()->injectUserLogin( $template );
46 static function confirmUserLogin( $u, $pass, &$retval ) {
47 return self::getInstance()->confirmUserLogin( $u, $pass, $retval );
50 static function injectEmailUser( &$form ) {
51 return self::getInstance()->injectEmailUser( $form );
54 static function confirmEmailUser( $from, $to, $subject, $text, &$error ) {
55 return self::getInstance()->confirmEmailUser( $from, $to, $subject, $text, $error );
59 class CaptchaSpecialPage extends UnlistedSpecialPage {
60 function execute( $par ) {
62 $instance = ConfirmEditHooks::getInstance();
65 if ( method_exists( $instance, 'showImage' ) )
66 return $instance->showImage();
69 return $instance->showHelp();
75 function SimpleCaptcha() {
76 global $wgCaptchaStorageClass;
77 $this->storage = new $wgCaptchaStorageClass;
80 function getCaptcha() {
81 $a = mt_rand( 0, 100 );
82 $b = mt_rand( 0, 10 );
83 $op = mt_rand( 0, 1 ) ? '+' : '-';
86 $answer = ( $op == '+' ) ? ( $a + $b ) : ( $a - $b );
87 return array( 'question' => $test, 'answer' => $answer );
90 function addCaptchaAPI( &$resultArr ) {
91 $captcha = $this->getCaptcha();
92 $index = $this->storeCaptcha( $captcha );
93 $resultArr['captcha']['type'] = 'simple';
94 $resultArr['captcha']['mime'] = 'text/plain';
95 $resultArr['captcha']['id'] = $index;
96 $resultArr['captcha']['question'] = $captcha['question'];
100 * Insert a captcha prompt into the edit form.
101 * This sample implementation generates a simple arithmetic operation;
102 * it would be easy to defeat by machine.
106 * @return string HTML
109 $captcha = $this->getCaptcha();
110 $index = $this->storeCaptcha( $captcha );
112 return "<p><label for=\"wpCaptchaWord\">{$captcha['question']}</label> = " .
113 Xml::element( 'input', array(
114 'name' => 'wpCaptchaWord',
115 'id' => 'wpCaptchaWord',
116 'tabindex' => 1 ) ) . // tab in before the edit textarea
118 Xml::element( 'input', array(
120 'name' => 'wpCaptchaId',
121 'id' => 'wpCaptchaId',
122 'value' => $index ) );
126 * Insert the captcha prompt into an edit form.
127 * @param OutputPage $out
129 function editCallback( &$out ) {
130 $out->addWikiText( $this->getMessage( $this->action ) );
131 $out->addHTML( $this->getForm() );
135 * Show a message asking the user to enter a captcha on edit
136 * The result will be treated as wiki text
138 * @param $action Action being performed
141 function getMessage( $action ) {
142 $name = 'captcha-' . $action;
143 $text = wfMsg( $name );
144 # Obtain a more tailored message, if possible, otherwise, fall back to
145 # the default for edits
146 return wfEmptyMsg( $name, $text ) ? wfMsg( 'captcha-edit' ) : $text;
151 * @fixme if multiple thingies insert a header, could break
153 * @return bool true to keep running callbacks
155 function injectEmailUser( &$form ) {
156 global $wgCaptchaTriggers, $wgOut, $wgUser;
157 if ( $wgCaptchaTriggers['sendemail'] ) {
158 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
159 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
162 $form->addFooterText(
163 "<div class='captcha'>" .
164 $wgOut->parse( $this->getMessage( 'sendemail' ) ) .
173 * @fixme if multiple thingies insert a header, could break
174 * @param SimpleTemplate $template
175 * @return bool true to keep running callbacks
177 function injectUserCreate( &$template ) {
178 global $wgCaptchaTriggers, $wgOut, $wgUser;
179 if ( $wgCaptchaTriggers['createaccount'] ) {
180 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
181 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
184 $template->set( 'header',
185 "<div class='captcha'>" .
186 $wgOut->parse( $this->getMessage( 'createaccount' ) ) .
194 * Inject a captcha into the user login form after a failed
195 * password attempt as a speedbump for mass attacks.
196 * @fixme if multiple thingies insert a header, could break
197 * @param SimpleTemplate $template
198 * @return bool true to keep running callbacks
200 function injectUserLogin( &$template ) {
201 if ( $this->isBadLoginTriggered() ) {
203 $template->set( 'header',
204 "<div class='captcha'>" .
205 $wgOut->parse( $this->getMessage( 'badlogin' ) ) .
213 * When a bad login attempt is made, increment an expiring counter
214 * in the memcache cloud. Later checks for this may trigger a
215 * captcha display to prevent too many hits from the same place.
217 * @param string $password
218 * @param int $retval authentication return value
219 * @return bool true to keep running callbacks
221 function triggerUserLogin( $user, $password, $retval ) {
222 global $wgCaptchaTriggers, $wgCaptchaBadLoginExpiration, $wgMemc;
223 if ( $retval == LoginForm::WRONG_PASS && $wgCaptchaTriggers['badlogin'] ) {
224 $key = $this->badLoginKey();
225 $count = $wgMemc->get( $key );
227 $wgMemc->add( $key, 0, $wgCaptchaBadLoginExpiration );
229 $count = $wgMemc->incr( $key );
235 * Check if a bad login has already been registered for this
236 * IP address. If so, require a captcha.
240 function isBadLoginTriggered() {
241 global $wgMemc, $wgCaptchaBadLoginAttempts;
242 return intval( $wgMemc->get( $this->badLoginKey() ) ) >= $wgCaptchaBadLoginAttempts;
246 * Check if the IP is allowed to skip captchas
248 function isIPWhitelisted() {
249 global $wgCaptchaWhitelistIP;
250 if ( $wgCaptchaWhitelistIP ) {
252 foreach ( $wgCaptchaWhitelistIP as $range ) {
253 if ( IP::isInRange( $ip, $range ) ) {
262 * Internal cache key for badlogin checks.
266 function badLoginKey() {
267 return wfMemcKey( 'captcha', 'badlogin', 'ip', wfGetIP() );
271 * Check if the submitted form matches the captcha session data provided
272 * by the plugin when the form was generated.
276 * @param string $answer
280 function keyMatch( $answer, $info ) {
281 return $answer == $info['answer'];
284 // ----------------------------------
287 * @param EditPage $editPage
288 * @param string $action (edit/create/addurl...)
289 * @return bool true if action triggers captcha on editPage's namespace
291 function captchaTriggers( &$editPage, $action ) {
292 global $wgCaptchaTriggers, $wgCaptchaTriggersOnNamespace;
293 // Special config for this NS?
294 if ( isset( $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action] ) )
295 return $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action];
297 return ( !empty( $wgCaptchaTriggers[$action] ) ); // Default
301 * @param EditPage $editPage
302 * @param string $newtext
303 * @param string $section
304 * @return bool true if the captcha should run
306 function shouldCheck( &$editPage, $newtext, $section, $merged = false ) {
308 $title = $editPage->mArticle->getTitle();
311 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
312 wfDebug( "ConfirmEdit: user group allows skipping captcha\n" );
315 if ( $this->isIPWhitelisted() )
319 global $wgEmailAuthentication, $ceAllowConfirmedEmail;
320 if ( $wgEmailAuthentication && $ceAllowConfirmedEmail &&
321 $wgUser->isEmailConfirmed() ) {
322 wfDebug( "ConfirmEdit: user has confirmed mail, skipping captcha\n" );
326 if ( $this->captchaTriggers( $editPage, 'edit' ) ) {
327 // Check on all edits
329 $this->trigger = sprintf( "edit trigger by '%s' at [[%s]]",
331 $title->getPrefixedText() );
332 $this->action = 'edit';
333 wfDebug( "ConfirmEdit: checking all edits...\n" );
337 if ( $this->captchaTriggers( $editPage, 'create' ) && !$editPage->mTitle->exists() ) {
338 // Check if creating a page
340 $this->trigger = sprintf( "Create trigger by '%s' at [[%s]]",
342 $title->getPrefixedText() );
343 $this->action = 'create';
344 wfDebug( "ConfirmEdit: checking on page creation...\n" );
348 if ( $this->captchaTriggers( $editPage, 'addurl' ) ) {
349 // Only check edits that add URLs
351 // Get links from the database
352 $oldLinks = $this->getLinksFromTracker( $title );
353 // Share a parse operation with Article::doEdit()
354 $editInfo = $editPage->mArticle->prepareTextForEdit( $newtext );
355 $newLinks = array_keys( $editInfo->output->getExternalLinks() );
357 // Get link changes in the slowest way known to man
358 $oldtext = $this->loadText( $editPage, $section );
359 $oldLinks = $this->findLinks( $editPage, $oldtext );
360 $newLinks = $this->findLinks( $editPage, $newtext );
363 $unknownLinks = array_filter( $newLinks, array( &$this, 'filterLink' ) );
364 $addedLinks = array_diff( $unknownLinks, $oldLinks );
365 $numLinks = count( $addedLinks );
367 if ( $numLinks > 0 ) {
369 $this->trigger = sprintf( "%dx url trigger by '%s' at [[%s]]: %s",
372 $title->getPrefixedText(),
373 implode( ", ", $addedLinks ) );
374 $this->action = 'addurl';
379 global $wgCaptchaRegexes;
380 if ( $wgCaptchaRegexes ) {
381 // Custom regex checks
382 $oldtext = $this->loadText( $editPage, $section );
384 foreach ( $wgCaptchaRegexes as $regex ) {
385 $newMatches = array();
386 if ( preg_match_all( $regex, $newtext, $newMatches ) ) {
387 $oldMatches = array();
388 preg_match_all( $regex, $oldtext, $oldMatches );
390 $addedMatches = array_diff( $newMatches[0], $oldMatches[0] );
392 $numHits = count( $addedMatches );
393 if ( $numHits > 0 ) {
395 $this->trigger = sprintf( "%dx %s at [[%s]]: %s",
399 $title->getPrefixedText(),
400 implode( ", ", $addedMatches ) );
401 $this->action = 'edit';
412 * Filter callback function for URL whitelisting
413 * @param string url to check
414 * @return bool true if unknown, false if whitelisted
417 function filterLink( $url ) {
418 global $wgCaptchaWhitelist;
419 $source = wfMsgForContent( 'captcha-addurl-whitelist' );
421 $whitelist = wfEmptyMsg( 'captcha-addurl-whitelist', $source )
423 : $this->buildRegexes( explode( "\n", $source ) );
425 $cwl = $wgCaptchaWhitelist !== false ? preg_match( $wgCaptchaWhitelist, $url ) : false;
426 $wl = $whitelist !== false ? preg_match( $whitelist, $url ) : false;
428 return !( $cwl || $wl );
432 * Build regex from whitelist
433 * @param string lines from [[MediaWiki:Captcha-addurl-whitelist]]
434 * @return string Regex or bool false if whitelist is empty
437 function buildRegexes( $lines ) {
438 # Code duplicated from the SpamBlacklist extension (r19197)
440 # Strip comments and whitespace, then remove blanks
441 $lines = array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) );
443 # No lines, don't make a regex which will match everything
444 if ( count( $lines ) == 0 ) {
445 wfDebug( "No lines\n" );
449 # It's faster using the S modifier even though it will usually only be run once
450 // $regex = 'http://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')';
451 // return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Si';
453 $regexStart = '/^https?:\/\/+[a-z0-9_\-.]*(';
457 foreach ( $lines as $line ) {
458 // FIXME: not very robust size check, but should work. :)
459 if ( $build === false ) {
461 } elseif ( strlen( $build ) + strlen( $line ) > $regexMax ) {
462 $regexes .= $regexStart .
463 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
467 $build .= '|' . $line;
470 if ( $build !== false ) {
471 $regexes .= $regexStart .
472 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
480 * Load external links from the externallinks table
482 function getLinksFromTracker( $title ) {
483 $dbr = wfGetDB( DB_SLAVE );
484 $id = $title->getArticleId(); // should be zero queries
485 $res = $dbr->select( 'externallinks', array( 'el_to' ),
486 array( 'el_from' => $id ), __METHOD__ );
488 while ( $row = $dbr->fetchObject( $res ) ) {
489 $links[] = $row->el_to;
495 * Backend function for confirmEdit() and confirmEditAPI()
496 * @return bool false if the CAPTCHA is rejected, true otherwise
498 private function doConfirmEdit( $editPage, $newtext, $section, $merged = false ) {
499 if ( $this->shouldCheck( $editPage, $newtext, $section, $merged ) ) {
500 if ( $this->passCaptcha() ) {
506 wfDebug( "ConfirmEdit: no need to show captcha.\n" );
512 * The main callback run on edit attempts.
513 * @param EditPage $editPage
514 * @param string $newtext
515 * @param string $section
516 * @param bool $merged
517 * @return bool true to continue saving, false to abort and show a captcha form
519 function confirmEdit( $editPage, $newtext, $section, $merged = false ) {
520 if ( defined( 'MW_API' ) ) {
522 # The CAPTCHA was already checked and approved
525 if ( !$this->doConfirmEdit( $editPage, $newtext, $section, $merged ) ) {
526 $editPage->showEditForm( array( &$this, 'editCallback' ) );
533 * A more efficient edit filter callback based on the text after section merging
534 * @param EditPage $editPage
535 * @param string $newtext
537 function confirmEditMerged( $editPage, $newtext ) {
538 return $this->confirmEdit( $editPage, $newtext, false, true );
542 function confirmEditAPI( $editPage, $newtext, &$resultArr ) {
543 if ( !$this->doConfirmEdit( $editPage, $newtext, false, false ) ) {
544 $this->addCaptchaAPI( $resultArr );
551 * Hook for user creation form submissions.
553 * @param string $message
554 * @return bool true to continue, false to abort user creation
556 function confirmUserCreate( $u, &$message ) {
557 global $wgCaptchaTriggers, $wgUser;
558 if ( $wgCaptchaTriggers['createaccount'] ) {
559 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
560 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
563 if ( $this->isIPWhitelisted() )
566 $this->trigger = "new account '" . $u->getName() . "'";
567 if ( !$this->passCaptcha() ) {
568 $message = wfMsg( 'captcha-createaccount-fail' );
576 * Hook for user login form submissions.
578 * @param string $message
579 * @return bool true to continue, false to abort user creation
581 function confirmUserLogin( $u, $pass, &$retval ) {
582 if ( $this->isBadLoginTriggered() ) {
583 if ( $this->isIPWhitelisted() )
586 $this->trigger = "post-badlogin login '" . $u->getName() . "'";
587 if ( !$this->passCaptcha() ) {
588 $message = wfMsg( 'captcha-badlogin-fail' );
589 // Emulate a bad-password return to confuse the shit out of attackers
590 $retval = LoginForm::WRONG_PASS;
598 * Check the captcha on Special:EmailUser
599 * @param $from MailAddress
600 * @param $to MailAddress
601 * @param $subject String
602 * @param $text String
603 * @param $error String reference
604 * @return Bool true to continue saving, false to abort and show a captcha form
606 function confirmEmailUser( $from, $to, $subject, $text, &$error ) {
607 global $wgCaptchaTriggers, $wgUser;
608 if ( $wgCaptchaTriggers['sendemail'] ) {
609 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
610 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
613 if ( $this->isIPWhitelisted() )
616 if ( defined( 'MW_API' ) ) {
618 # Asking for captchas in the API is really silly
619 $error = wfMsg( 'captcha-disabledinapi' );
622 $this->trigger = "{$wgUser->getName()} sending email";
623 if ( !$this->passCaptcha() ) {
624 $error = wfMsg( 'captcha-sendemail-fail' );
632 * Given a required captcha run, test form input for correct
633 * input on the open session.
634 * @return bool if passed, false if failed or new session
636 function passCaptcha() {
637 $info = $this->retrieveCaptcha();
640 if ( $this->keyMatch( $wgRequest->getVal( 'wpCaptchaWord' ), $info ) ) {
641 $this->log( "passed" );
642 $this->clearCaptcha( $info );
645 $this->clearCaptcha( $info );
646 $this->log( "bad form input" );
650 $this->log( "new captcha session" );
656 * Log the status and any triggering info for debugging or statistics
657 * @param string $message
659 function log( $message ) {
660 wfDebugLog( 'captcha', 'ConfirmEdit: ' . $message . '; ' . $this->trigger );
664 * Generate a captcha session ID and save the info in PHP's session storage.
665 * (Requires the user to have cookies enabled to get through the captcha.)
667 * A random ID is used so legit users can make edits in multiple tabs or
668 * windows without being unnecessarily hobbled by a serial order requirement.
669 * Pass the returned id value into the edit form as wpCaptchaId.
671 * @param array $info data to store
672 * @return string captcha ID key
674 function storeCaptcha( $info ) {
675 if ( !isset( $info['index'] ) ) {
676 // Assign random index if we're not udpating
677 $info['index'] = strval( mt_rand() );
679 $this->storage->store( $info['index'], $info );
680 return $info['index'];
684 * Fetch this session's captcha info.
685 * @return mixed array of info, or false if missing
687 function retrieveCaptcha() {
689 $index = $wgRequest->getVal( 'wpCaptchaId' );
690 return $this->storage->retrieve( $index );
694 * Clear out existing captcha info from the session, to ensure
695 * it can't be reused.
697 function clearCaptcha( $info ) {
698 $this->storage->clear( $info['index'] );
702 * Retrieve the current version of the page or section being edited...
703 * @param EditPage $editPage
704 * @param string $section
708 function loadText( $editPage, $section ) {
709 $rev = Revision::newFromTitle( $editPage->mTitle );
710 if ( is_null( $rev ) ) {
713 $text = $rev->getText();
714 if ( $section != '' ) {
715 return Article::getSection( $text, $section );
723 * Extract a list of all recognized HTTP links in the text.
724 * @param string $text
725 * @return array of strings
727 function findLinks( &$editpage, $text ) {
728 global $wgParser, $wgUser;
730 $options = new ParserOptions();
731 $text = $wgParser->preSaveTransform( $text, $editpage->mTitle, $wgUser, $options );
732 $out = $wgParser->parse( $text, $editpage->mTitle, $options );
734 return array_keys( $out->getExternalLinks() );
738 * Show a page explaining what this wacky thing is.
740 function showHelp() {
741 global $wgOut, $ceAllowConfirmedEmail;
742 $wgOut->setPageTitle( wfMsg( 'captchahelp-title' ) );
743 $wgOut->addWikiText( wfMsg( 'captchahelp-text' ) );
744 if ( $this->storage->cookiesNeeded() ) {
745 $wgOut->addWikiText( wfMsg( 'captchahelp-cookies-needed' ) );
750 class CaptchaSessionStore {
751 function store( $index, $info ) {
752 $_SESSION['captcha' . $info['index']] = $info;
755 function retrieve( $index ) {
756 if ( isset( $_SESSION['captcha' . $index] ) ) {
757 return $_SESSION['captcha' . $index];
763 function clear( $index ) {
764 unset( $_SESSION['captcha' . $index] );
767 function cookiesNeeded() {
772 class CaptchaCacheStore {
773 function store( $index, $info ) {
774 global $wgMemc, $wgCaptchaSessionExpiration;
775 $wgMemc->set( wfMemcKey( 'captcha', $index ), $info,
776 $wgCaptchaSessionExpiration );
779 function retrieve( $index ) {
781 $info = $wgMemc->get( wfMemcKey( 'captcha', $index ) );
789 function clear( $index ) {
791 $wgMemc->delete( wfMemcKey( 'captcha', $index ) );
794 function cookiesNeeded() {