From 1cc3a57296ffa6688d62362b45f71bcbd6be78f2 Mon Sep 17 00:00:00 2001 From: Tyler Anthony Romeo Date: Thu, 7 Feb 2013 16:56:54 -0500 Subject: [PATCH] Send a cookie with autoblocks to prevent vandalism. Send a cookie with blocks that have autoblock turned on so that the user will be identified to MediaWiki and any IP they try to edit anonymously from will be blocked, even without logging in to the originally blocked account. Additionally, the block info is stored in local storage as well as an even stronger deterrence. Note: this is meant to deter normal vandals, i.e., not attackers who know what cookies and local storage are and will be actively removing the cookie. This feature is disabled by default, and can be enabled with the new $wgCookieSetOnAutoblock configuration variable (by setting it to true); The cookie will expire at the same time as the block or after $wgCookieExpiration (whichever is sooner). Bug: T5233 Bug: T147610 Change-Id: Ic3383af56c555c1592d272490ff4da683b9d7b1b --- RELEASE-NOTES-1.28 | 6 + includes/Block.php | 27 +++ includes/DefaultSettings.php | 6 + includes/EditPage.php | 5 +- includes/user/User.php | 42 ++++- resources/Resources.php | 5 + .../mediawiki/mediawiki.user.blockcookie.js | 23 +++ tests/phpunit/includes/user/UserTest.php | 158 ++++++++++++++++++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 resources/src/mediawiki/mediawiki.user.blockcookie.js diff --git a/RELEASE-NOTES-1.28 b/RELEASE-NOTES-1.28 index 58ae23b39b..f40bcae22d 100644 --- a/RELEASE-NOTES-1.28 +++ b/RELEASE-NOTES-1.28 @@ -56,6 +56,10 @@ production. explain your use case(s). * New config variable $wgCSPFalsePositiveUrls to control what URLs to ignore in upcoming Content-Security-Policy feature's reporting. +* A new configuration variable has been added: $wgCookieSetOnAutoblock. This + determines whether to set a cookie when a user is autoblocked. Doing so means + that a blocked user, even after logging out and moving to a new IP address, + will still be blocked. === New features in 1.28 === * User::isBot() method for checking if an account is a bot role account. @@ -86,6 +90,8 @@ production. * Added new hooks, 'ApiQueryBaseBeforeQuery', 'ApiQueryBaseAfterQuery', and 'ApiQueryBaseProcessRow', to make it easier for extensions to add 'prop' and 'show' parameters to existing API query modules. +* (T5233) A cookie can now be set when a user is autoblocked, to track that user if + they move to a new IP address. This is disabled by default. === External library changes in 1.28 === diff --git a/includes/Block.php b/includes/Block.php index a11ba26484..8663d0328c 100644 --- a/includes/Block.php +++ b/includes/Block.php @@ -1417,6 +1417,33 @@ class Block { $this->blocker = $user; } + /** + * Set the 'BlockID' cookie to this block's ID and expiry time. The cookie's expiry will be + * the same as the block's, unless it's greater than $wgCookieExpiration in which case + * $wgCookieExpiration will be used instead (defaults to 30 days). + * + * An empty value can also be set, in order to retain the cookie but remove the block ID + * (e.g. as used in User::getBlockedStatus). + * + * @param WebResponse $response The response on which to set the cookie. + * @param boolean $setEmpty Whether to set the cookie's value to the empty string. + */ + public function setCookie( WebResponse $response, $setEmpty = false ) { + // Calculate the default expiry time. + $config = RequestContext::getMain()->getConfig(); + $defaultExpiry = wfTimestamp() + $config->get( 'CookieExpiration' ); + + // Use the Block's expiry time only if it's less than the default. + $expiry = wfTimestamp( TS_UNIX, $this->getExpiry() ); + if ( $expiry > $defaultExpiry ) { + // The *default* default expiry is 30 days. + $expiry = $defaultExpiry; + } + + $cookieValue = $setEmpty ? '' : $this->getId(); + $response->setCookie( 'BlockID', $cookieValue, $expiry ); + } + /** * Get the key and parameters for the corresponding error message. * diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 9d8ccf89c4..ca32a63376 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -5926,6 +5926,12 @@ $wgCacheVaryCookies = []; */ $wgSessionName = false; +/** + * Whether to set a cookie when a user is autoblocked. Doing so means that a blocked user, even + * after logging out and moving to a new IP address, will still be blocked. + */ +$wgCookieSetOnAutoblock = false; + /** @} */ # end of cookie settings } /************************************************************************//** diff --git a/includes/EditPage.php b/includes/EditPage.php index 82ddee019c..5e31a5cf0a 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -2326,9 +2326,12 @@ class EditPage { } function setHeaders() { - global $wgOut, $wgUser, $wgAjaxEditStash; + global $wgOut, $wgUser, $wgAjaxEditStash, $wgCookieSetOnAutoblock; $wgOut->addModules( 'mediawiki.action.edit' ); + if ( $wgCookieSetOnAutoblock === true ) { + $wgOut->addModules( 'mediawiki.user.blockcookie' ); + } $wgOut->addModuleStyles( 'mediawiki.action.edit.styles' ); if ( $wgUser->getOption( 'showtoolbar' ) ) { diff --git a/includes/user/User.php b/includes/user/User.php index 273d555eff..798e1e95ed 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -1200,13 +1200,29 @@ class User implements IDBAccessObject { $user = $session->getUser(); if ( $user->isLoggedIn() ) { $this->loadFromUserObject( $user ); + + // If this user is autoblocked, set a cookie to track the Block. This has to be done on + // every session load, because an autoblocked editor might not edit again from the same + // IP address after being blocked. + $config = RequestContext::getMain()->getConfig(); + if ( $config->get( 'CookieSetOnAutoblock' ) === true ) { + $block = $this->getBlock(); + $shouldSetCookie = $this->getRequest()->getCookie( 'BlockID' ) === null + && $block + && $block->getType() === Block::TYPE_USER + && $block->isAutoblocking(); + if ( $shouldSetCookie ) { + wfDebug( __METHOD__ . ': User is autoblocked, setting cookie to track' ); + $block->setCookie( $this->getRequest()->response() ); + } + } + // Other code expects these to be set in the session, so set them. $session->set( 'wsUserID', $this->getId() ); $session->set( 'wsUserName', $this->getName() ); $session->set( 'wsToken', $this->getToken() ); return true; } - return false; } @@ -1609,6 +1625,30 @@ class User implements IDBAccessObject { // User/IP blocking $block = Block::newFromTarget( $this, $ip, !$bFromSlave ); + // If no block has been found, check for a cookie indicating that the user is blocked. + $blockCookieVal = (int)$this->getRequest()->getCookie( 'BlockID' ); + if ( !$block instanceof Block && $blockCookieVal > 0 ) { + // Load the Block from the ID in the cookie. + $tmpBlock = Block::newFromID( $blockCookieVal ); + if ( $tmpBlock instanceof Block ) { + // Check the validity of the block. + $blockIsValid = $tmpBlock->getType() == Block::TYPE_USER + && !$tmpBlock->isExpired() + && $tmpBlock->isAutoblocking(); + $config = RequestContext::getMain()->getConfig(); + $useBlockCookie = ( $config->get( 'CookieSetOnAutoblock' ) === true ); + if ( $blockIsValid && $useBlockCookie ) { + // Use the block. + $block = $tmpBlock; + } else { + // If the block is not valid, clear the block cookie (but don't delete it, + // because it needs to be cleared from LocalStorage as well and an empty string + // value is checked for in the mediawiki.user.blockcookie module). + $block->setCookie( $this->getRequest()->response(), true ); + } + } + } + // Proxy blocking if ( !$block instanceof Block && $ip !== null && !in_array( $ip, $wgProxyWhitelist ) ) { // Local list diff --git a/resources/Resources.php b/resources/Resources.php index 8c3b67d1d3..1dfafc7ce2 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1406,6 +1406,11 @@ return [ 'dependencies' => 'mediawiki.util', 'targets' => [ 'desktop', 'mobile' ], ], + 'mediawiki.user.blockcookie' => [ + 'scripts' => 'resources/src/mediawiki/mediawiki.user.blockcookie.js', + 'dependencies' => [ 'mediawiki.cookie', 'mediawiki.storage' ], + 'targets' => [ 'desktop', 'mobile' ], + ], 'mediawiki.user' => [ 'scripts' => 'resources/src/mediawiki/mediawiki.user.js', 'dependencies' => [ diff --git a/resources/src/mediawiki/mediawiki.user.blockcookie.js b/resources/src/mediawiki/mediawiki.user.blockcookie.js new file mode 100644 index 0000000000..ffff039e86 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.user.blockcookie.js @@ -0,0 +1,23 @@ +( function ( mw ) { + + // If a user has been autoblocked, a cookie is set. + // Its value is replicated here in localStorage to guard against cookie-removal. + // This module will only be loaded when $wgCookieSetOnAutoblock is true. + // Ref: https://phabricator.wikimedia.org/T5233 + + if ( !mw.cookie.get( 'BlockID' ) && mw.storage.get( 'blockID' ) ) { + // The block ID exists in storage, but not in the cookie. + mw.cookie.set( 'BlockID', mw.storage.get( 'blockID' ) ); + + } else if ( parseInt( mw.cookie.get( 'BlockID' ), 10 ) > 0 && !mw.storage.get( 'blockID' ) ) { + // The block ID exists in the cookie, but not in storage. + // (When a block expires the cookie remains but its value is '', hence the integer check above.) + mw.storage.set( 'blockID', mw.cookie.get( 'BlockID' ) ); + + } else if ( mw.cookie.get( 'BlockID' ) === '' && mw.storage.get( 'blockID' ) ) { + // If only the empty string is in the cookie, remove the storage value. The block is no longer valid. + mw.storage.remove( 'blockID' ); + + } + +}( mediaWiki ) ); diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 199fc8f1bc..a9c4eae6aa 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -578,4 +578,162 @@ class UserTest extends MediaWikiTestCase { $users->rewind(); $this->assertTrue( $user->equals( $users->current() ) ); } + + /** + * When a user is autoblocked a cookie is set with which to track them + * in case they log out and change IP addresses. + * @link https://phabricator.wikimedia.org/T5233 + */ + public function testAutoblockCookies() { + // Set up the bits of global configuration that we use. + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => true, + 'wgCookiePrefix' => 'wmsitetitle', + ] ); + + // 1. Log in a test user, and block them. + $user1tmp = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $user1tmp ); + $expiryFiveDays = time() + ( 5 * 24 * 60 * 60 ); + $block = new Block( [ + 'enableAutoblock' => true, + 'expiry' => wfTimestamp( TS_MW, $expiryFiveDays ), + ] ); + $block->setTarget( $user1tmp ); + $block->insert(); + $user1 = User::newFromSession( $request1 ); + $user1->mBlock = $block; + $user1->load(); + + // Confirm that the block has been applied as required. + $this->assertTrue( $user1->isLoggedIn() ); + $this->assertTrue( $user1->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $block->getId() ); + + // Test for the desired cookie name, value, and expiry. + $cookies = $request1->response()->getCookies(); + $this->assertArrayHasKey( 'wmsitetitleBlockID', $cookies ); + $this->assertEquals( $block->getId(), $cookies['wmsitetitleBlockID']['value'] ); + $this->assertEquals( $expiryFiveDays, $cookies['wmsitetitleBlockID']['expire'] ); + + // 2. Create a new request, set the cookies, and see if the (anon) user is blocked. + $request2 = new FauxRequest(); + $request2->setCookie( 'BlockID', $block->getId() ); + $user2 = User::newFromSession( $request2 ); + $user2->load(); + $this->assertNotEquals( $user1->getId(), $user2->getId() ); + $this->assertNotEquals( $user1->getToken(), $user2->getToken() ); + $this->assertTrue( $user2->isAnon() ); + $this->assertFalse( $user2->isLoggedIn() ); + $this->assertTrue( $user2->isBlocked() ); + $this->assertEquals( true, $user2->getBlock()->isAutoblocking() ); // Non-strict type-check. + // Can't directly compare the objects becuase of member type differences. + // One day this will work: $this->assertEquals( $block, $user2->getBlock() ); + $this->assertEquals( $block->getId(), $user2->getBlock()->getId() ); + $this->assertEquals( $block->getExpiry(), $user2->getBlock()->getExpiry() ); + + // 3. Finally, set up a request as a new user, and the block should still be applied. + $user3tmp = $this->getTestUser()->getUser(); + $request3 = new FauxRequest(); + $request3->getSession()->setUser( $user3tmp ); + $request3->setCookie( 'BlockID', $block->getId() ); + $user3 = User::newFromSession( $request3 ); + $user3->load(); + $this->assertTrue( $user3->isLoggedIn() ); + $this->assertTrue( $user3->isBlocked() ); + $this->assertEquals( true, $user3->getBlock()->isAutoblocking() ); // Non-strict type-check. + + // Clean up. + $block->delete(); + } + + /** + * Make sure that no cookie is set to track autoblocked users + * when $wgCookieSetOnAutoblock is false. + */ + public function testAutoblockCookiesDisabled() { + // Set up the bits of global configuration that we use. + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => false, + 'wgCookiePrefix' => 'wm_no_cookies', + ] ); + + // 1. Log in a test user, and block them. + $testUser = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $testUser ); + $block = new Block( [ 'enableAutoblock' => true ] ); + $block->setTarget( $testUser ); + $block->insert(); + $user = User::newFromSession( $request1 ); + $user->mBlock = $block; + $user->load(); + + // 2. Test that the cookie IS NOT present. + $this->assertTrue( $user->isLoggedIn() ); + $this->assertTrue( $user->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $user->getBlockId() ); + $this->assertGreaterThanOrEqual( $block->getId(), $user->getBlockId() ); + $cookies = $request1->response()->getCookies(); + $this->assertArrayNotHasKey( 'wm_no_cookiesBlockID', $cookies ); + + // Clean up. + $block->delete(); + } + + /** + * When a user is autoblocked and a cookie is set to track them, the expiry time of the cookie + * should match the block's expiry. If the block is infinite, the cookie expiry time should + * match $wgCookieExpiration. If the expiry time is changed, the cookie's should change with it. + */ + public function testAutoblockCookieInfiniteExpiry() { + $cookieExpiration = 20 * 24 * 60 * 60; // 20 days + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => true, + 'wgCookieExpiration' => $cookieExpiration, + 'wgCookiePrefix' => 'wm_infinite_block', + ] ); + // 1. Log in a test user, and block them indefinitely. + $user1Tmp = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $user1Tmp ); + $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] ); + $block->setTarget( $user1Tmp ); + $block->insert(); + $user1 = User::newFromSession( $request1 ); + $user1->mBlock = $block; + $user1->load(); + + // 2. Test the cookie's expiry timestamp. + $this->assertTrue( $user1->isLoggedIn() ); + $this->assertTrue( $user1->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $user1->getBlockId() ); + $cookies = $request1->response()->getCookies(); + // Calculate the expected cookie expiry date. + $this->assertArrayHasKey( 'wm_infinite_blockBlockID', $cookies ); + $this->assertEquals( time() + $cookieExpiration, $cookies['wm_infinite_blockBlockID']['expire'] ); + + // 3. Change the block's expiry (to 2 days), and the cookie's should be changed also. + $newExpiry = time() + 2 * 24 * 60 * 60; + $block->mExpiry = wfTimestamp( TS_MW, $newExpiry ); + $block->update(); + $user2tmp = $this->getTestUser()->getUser(); + $request2 = new FauxRequest(); + $request2->getSession()->setUser( $user2tmp ); + $user2 = User::newFromSession( $request2 ); + $user2->mBlock = $block; + $user2->load(); + $cookies = $request2->response()->getCookies(); + $this->assertEquals( $newExpiry, $cookies['wm_infinite_blockBlockID']['expire'] ); + + // Clean up. + $block->delete(); + } } -- 2.20.1