diff --git a/ConfirmEdit.php b/ConfirmEdit.php index 772c65eb3..cd1e04e14 100644 --- a/ConfirmEdit.php +++ b/ConfirmEdit.php @@ -1,10 +1,31 @@ + * http://www.mediawiki.org/ + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @package MediaWiki + * @subpackage Extensions + */ if ( defined( 'MEDIAWIKI' ) ) { @@ -12,6 +33,14 @@ global $wgExtensionFunctions, $wgGroupPermissions; $wgExtensionFunctions[] = 'ceSetup'; +/** + * The 'skipcaptcha' permission key can be given out to + * let known-good users perform triggering actions without + * having to go through the captcha. + * + * By default, sysops and registered bot accounts will be + * able to skip, while others have to go through it. + */ $wgGroupPermissions['*' ]['skipcaptcha'] = false; $wgGroupPermissions['user' ]['skipcaptcha'] = false; $wgGroupPermissions['autoconfirmed']['skipcaptcha'] = false; @@ -22,6 +51,17 @@ global $wgCaptcha, $wgCaptchaClass, $wgCaptchaTriggers; $wgCaptcha = null; $wgCaptchaClass = 'SimpleCaptcha'; +/** + * Currently the captcha works only for page edits. + * + * If the 'edit' trigger is on, *every* edit will trigger the captcha. + * This may be useful for protecting against vandalbot attacks. + * + * If using the default 'addurl' trigger, the captcha will trigger on + * edits that include URLs that aren't in the current version of the page. + * This should catch automated linkspammers without annoying people when + * they make more typical edits. + */ $wgCaptchaTriggers = array(); $wgCaptchaTriggers['edit'] = false; // Would check on every edit $wgCaptchaTriggers['addurl'] = true; // Check on edits that add URLs @@ -87,6 +127,55 @@ function wfSpecialCaptcha( $par = null ) { } class SimpleCaptcha { + /** + * Insert a captcha prompt into the edit form. + * This sample implementation generates a simple arithmetic operation; + * it would be easy to defeat by machine. + * + * Override this! + * + * @param OutputPage $out + */ + function formCallback( &$out ) { + $a = mt_rand(0, 100); + $b = mt_rand(0, 10); + $op = mt_rand(0, 1) ? '+' : '-'; + + $test = "$a $op $b"; + $answer = ($op == '+') ? ($a + $b) : ($a - $b); + + $index = $this->storeCaptcha( array( 'answer' => $answer ) ); + + $out->addWikiText( wfMsg( "captcha-short" ) ); + $out->addHTML( "

= " . + wfElement( 'input', array( + 'name' => 'wpCaptchaWord', + 'id' => 'wpCaptchaWord', + 'tabindex' => 1 ) ) . // tab in before the edit textarea + "

\n" . + wfElement( 'input', array( + 'type' => 'hidden', + 'name' => 'wpCaptchaId', + 'id' => 'wpCaptchaId', + 'value' => $index ) ) ); + } + + /** + * Check if the submitted form matches the captcha session data provided + * by the plugin when the form was generated. + * + * Override this! + * + * @param WebRequest $request + * @param array $info + * @return bool + */ + function keyMatch( $request, $info ) { + return $request->getVal( 'wpCaptchaWord' ) == $info['answer']; + } + + // ---------------------------------- + /** * @param EditPage $editPage * @param string $newtext @@ -96,21 +185,21 @@ class SimpleCaptcha { function shouldCheck( &$editPage, $newtext, $section ) { global $wgUser; if( $wgUser->isAllowed( 'skipcaptcha' ) ) { - wfDebug( "SimpleCaptcha: user group allows skipping captcha\n" ); + wfDebug( "ConfirmEdit: user group allows skipping captcha\n" ); return false; } global $wgEmailAuthentication, $ceAllowConfirmedEmail; if( $wgEmailAuthentication && $ceAllowConfirmedEmail && $wgUser->isEmailConfirmed() ) { - wfDebug( "SimpleCaptcha: user has confirmed mail, skipping captcha\n" ); + wfDebug( "ConfirmEdit: user has confirmed mail, skipping captcha\n" ); return false; } global $wgCaptchaTriggers; if( !empty( $wgCaptchaTriggers['edit'] ) ) { // Check on all edits - wfDebug( "SimpleCaptcha: checking all edits...\n" ); + wfDebug( "ConfirmEdit: checking all edits...\n" ); return true; } @@ -138,52 +227,77 @@ class SimpleCaptcha { return false; } + /** + * The main callback run on edit attempts. + * @param EditPage $editPage + * @param string $newtext + * @param string $section + * @param bool true to continue saving, false to abort and show a captcha form + */ function confirmEdit( &$editPage, $newtext, $section ) { if( $this->shouldCheck( $editPage, $newtext, $section ) ) { - if( $this->keyMatch() ) { - wfDebug( "ConfirmEdit given proper key from form, passing.\n" ); - return true; + $info = $this->retrieveCaptcha(); + if( $info ) { + global $wgRequest; + if( $this->keyMatch( $wgRequest, $info ) ) { + wfDebug( "ConfirmEdit given proper key from form, passing.\n" ); + return true; + } else { + wfDebug( "ConfirmEdit missing form key, prompting.\n" ); + } } else { - wfDebug( "ConfirmEdit missing form key, prompting.\n" ); - $editPage->showEditForm( array( &$this, 'formCallback' ) ); - return false; + wfDebug( "ConfirmEdit: no session captcha key set, this is new visitor.\n" ); } + $editPage->showEditForm( array( &$this, 'formCallback' ) ); + return false; } else { wfDebug( "ConfirmEdit: no new links.\n" ); return true; } } - function keyMatch() { - if( !isset( $_SESSION['ceAnswerVar'] ) ) { - wfDebug( "ConfirmEdit no session captcha key set, this is new visitor.\n" ); + /** + * Generate a captcha session ID and save the info in PHP's session storage. + * (Requires the user to have cookies enabled to get through the captcha.) + * + * A random ID is used so legit users can make edits in multiple tabs or + * windows without being unnecessarily hobbled by a serial order requirement. + * Pass the returned id value into the edit form as wpCaptchaId. + * + * @param array $info data to store + * @param string $index optional, to overwrite used session + * @return string captcha ID key + */ + function storeCaptcha( $info, $index=null ) { + if( is_null( $index ) ) { + $index = strval( mt_rand() ); + $info['index'] = $index; + } + $_SESSION['captcha' . $index] = $info; + return $index; + } + + /** + * Fetch this session's captcha info. + * @return mixed array of info, or false if missing + */ + function retrieveCaptcha() { + global $wgRequest; + $index = $wgRequest->getVal( 'wpCaptchaId' ); + if( isset( $_SESSION['captcha' . $index] ) ) { + return $_SESSION['captcha' . $index]; + } else { return false; } - global $wgRequest; - return $wgRequest->getVal( $_SESSION['ceAnswerVar'] ) == $_SESSION['ceAnswer']; - } - - function formCallback( &$out ) { - $source = 'ceSource' . mt_rand(); - $dest = 'ceConfirm' . mt_rand(); - - $a = mt_rand(0, 100); - $b = mt_rand(0, 10); - $op = mt_rand(0, 1) ? '+' : '-'; - - $test = "$a $op $b"; - $answer = ($op == '+') ? ($a + $b) : ($a - $b); - $_SESSION['ceAnswer'] = $answer; - $_SESSION['ceAnswerVar'] = $dest; - - - $out->addWikiText( wfMsg( "captcha-short" ) ); - $out->addHTML( << =

-END - ); } + /** + * Retrieve the current version of the page or section being edited... + * @param EditPage $editPage + * @param string $section + * @return string + * @access private + */ function loadText( $editPage, $section ) { $rev = Revision::newFromTitle( $editPage->mTitle ); if( is_null( $rev ) ) { @@ -198,6 +312,11 @@ END } } + /** + * Extract a list of all recognized HTTP links in the text. + * @param string $text + * @return array of strings + */ function findLinks( $text ) { $regex = '/((?:' . HTTP_PROTOCOLS . ')' . EXT_LINK_URL_CLASS . '+)/'; @@ -208,6 +327,9 @@ END } } + /** + * Show a page explaining what this wacky thing is. + */ function showHelp() { global $wgOut, $ceAllowConfirmedEmail; $wgOut->setPageTitle( wfMsg( 'captchahelp-title' ) ); diff --git a/FancyCaptcha.php b/FancyCaptcha.php index ec867e873..4a6b438e4 100644 --- a/FancyCaptcha.php +++ b/FancyCaptcha.php @@ -1,4 +1,29 @@ + * http://www.mediawiki.org/ + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @package MediaWiki + * @subpackage Extensions + */ if ( defined( 'MEDIAWIKI' ) ) { @@ -10,27 +35,26 @@ $wgCaptchaSecret = "CHANGE_THIS_SECRET!"; class FancyCaptcha extends SimpleCaptcha { - function keyMatch() { - global $wgRequest, $wgCaptchaSecret; + /** + * Check if the submitted form matches the captcha session data provided + * by the plugin when the form was generated. + * + * @param WebRequest $request + * @param array $info + * @return bool + */ + function keyMatch( $request, $info ) { + global $wgCaptchaSecret; - if( !isset( $_SESSION['ceAnswerVar'] ) ) { - wfDebug( "FancyCaptcha: no session captcha key set, this is new visitor.\n" ); - return false; - } - - $var = @$_SESSION['ceAnswerVar']; - $salt = @$_SESSION['captchaSalt']; - $hash = @$_SESSION['captchaHash']; - - $answer = $wgRequest->getVal( $var ); - $digest = $wgCaptchaSecret . $salt . $answer . $wgCaptchaSecret . $salt; + $answer = $request->getVal( 'wpCaptchaWord' ); + $digest = $wgCaptchaSecret . $info['salt'] . $answer . $wgCaptchaSecret . $info['salt']; $answerHash = substr( md5( $digest ), 0, 16 ); - if( $answerHash == $hash ) { + if( $answerHash == $info['hash'] ) { wfDebug( "FancyCaptcha: answer hash matches expected $hash\n" ); return true; } else { - wfDebug( "FancyCaptcha: answer hashes to $answerHash, expected $hash\n" ); + wfDebug( "FancyCaptcha: answer hashes to $answerHash, expected {$info['hash']}\n" ); return false; } } @@ -39,30 +63,40 @@ class FancyCaptcha extends SimpleCaptcha { * Insert the captcha prompt into the edit form. */ function formCallback( &$out ) { - $dest = 'wpCaptchaWord' . mt_rand(); - - $img = $this->pickImage(); - if( !$img ) { + $info = $this->pickImage(); + if( !$info ) { die( "out of captcha images; this shouldn't happen" ); } - $_SESSION['ceAnswerVar'] = $dest; - $_SESSION['captchaHash'] = $img['hash']; - $_SESSION['captchaSalt'] = $img['salt']; - $_SESSION['captchaViewed'] = false; - wfDebug( "Picked captcha with hash ${img['hash']}, salt ${img['salt']}.\n" ); + // Generate a random key for use of this captcha image in this session. + // This is needed so multiple edits in separate tabs or windows can + // go through without extra pain. + $index = $this->storeCaptcha( $info ); + + wfDebug( "Captcha id $index using hash ${info['hash']}, salt ${info['salt']}.\n" ); $title = Title::makeTitle( NS_SPECIAL, 'Captcha/image' ); - $out->addWikiText( wfMsg( "captcha-short" ) ); + $out->addWikiText( wfMsg( "captcha-short" ) ); + $out->addHTML( "

" . wfElement( 'img', array( - 'src' => $title->getLocalUrl(), - 'width' => $img['width'], - 'height' => $img['height'], + 'src' => $title->getLocalUrl( 'wpCaptchaId=' . urlencode( $index ) ), + 'width' => $info['width'], + 'height' => $info['height'], 'alt' => '' ) ) . "

\n" . - "

" ); + wfElement( 'input', array( + 'type' => 'hidden', + 'name' => 'wpCaptchaId', + 'id' => 'wpCaptchaId', + 'value' => $index ) ) . + "

" . + wfElement( 'input', array( + 'name' => 'wpCaptchaWord', + 'id' => 'wpCaptchaWord', + 'tabindex' => 1 ) ) . // tab in before the edit textarea + "

\n" ); } /** @@ -88,7 +122,8 @@ class FancyCaptcha extends SimpleCaptcha { 'salt' => $matches[1], 'hash' => $matches[2], 'width' => $size[0], - 'height' => $size[1] + 'height' => $size[1], + 'viewed' => false, ); if( $count++ == $n ) { break; @@ -116,29 +151,33 @@ class FancyCaptcha extends SimpleCaptcha { } function showImage() { - global $wgOut; - $wgOut->disable(); - if( !empty( $_SESSION['captchaViewed'] ) ) { - wfHttpError( 403, 'Access Forbidden', "Can't view captcha image a second time." ); - return false; - } - $_SESSION['captchaViewed'] = wfTimestamp(); + global $wgOut, $wgRequest; + global $wgCaptchaDirectory; - if( isset( $_SESSION['captchaSalt'] ) ) { - $salt = $_SESSION['captchaSalt']; - if( isset( $_SESSION['captchaHash'] ) ) { - $hash = $_SESSION['captchaHash']; - - global $wgCaptchaDirectory; - $file = $wgCaptchaDirectory . DIRECTORY_SEPARATOR . "image_{$salt}_{$hash}.png"; - if( file_exists( $file ) ) { - header( 'Content-type: image/png' ); - readfile( $file ); - } + $wgOut->disable(); + + $info = $this->retrieveCaptcha(); + if( $info ) { + if( $info['viewed'] ) { + wfHttpError( 403, 'Access Forbidden', "Can't view captcha image a second time." ); + return false; + } + + $info['viewed'] = wfTimestamp(); + $this->storeCaptcha( $info, $info['index'] ); + + $salt = $info['salt']; + $hash = $info['hash']; + $file = $wgCaptchaDirectory . DIRECTORY_SEPARATOR . "image_{$salt}_{$hash}.png"; + + if( file_exists( $file ) ) { + header( 'Content-type: image/png' ); + readfile( $file ); + return true; } - } else { - wfHttpError( 500, 'Internal Error', 'Requested bogus captcha image' ); } + wfHttpError( 500, 'Internal Error', 'Requested bogus captcha image' ); + return false; } }