Send a cookie with autoblocks to prevent vandalism.
authorTyler Anthony Romeo <tylerromeo@gmail.com>
Thu, 7 Feb 2013 21:56:54 +0000 (16:56 -0500)
committerKaldari <rkaldari@wikimedia.org>
Wed, 16 Nov 2016 18:29:46 +0000 (10:29 -0800)
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
includes/Block.php
includes/DefaultSettings.php
includes/EditPage.php
includes/user/User.php
resources/Resources.php
resources/src/mediawiki/mediawiki.user.blockcookie.js [new file with mode: 0644]
tests/phpunit/includes/user/UserTest.php

index 58ae23b..f40bcae 100644 (file)
@@ -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 ===
 
index a11ba26..8663d03 100644 (file)
@@ -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.
         *
index 9d8ccf8..ca32a63 100644 (file)
@@ -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 }
 
 /************************************************************************//**
index 82ddee0..5e31a5c 100644 (file)
@@ -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' ) ) {
index 273d555..798e1e9 100644 (file)
@@ -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
index 8c3b67d..1dfafc7 100644 (file)
@@ -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 (file)
index 0000000..ffff039
--- /dev/null
@@ -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 ) );
index 199fc8f..a9c4eae 100644 (file)
@@ -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();
+       }
 }