mediawiki-extensions-AbuseF.../includes/AbuseLogger.php
Matěj Suchánek 08f42494d3 Send CheckUser the IP address on account creation
We mask the IP address on purpose, so that it is not
leaked to the abuse log. This breaks CheckUser because
it attempts to assign an actor id to the "fake"
(uncreated) user account. So unmask the IP address
when we send the data to CheckUser.

Bug: T233004
Change-Id: Ib58193927bc8254d36a8de0fd1b5f9fba68a0cb0
2022-08-01 09:17:35 +02:00

316 lines
9.4 KiB
PHP

<?php
namespace MediaWiki\Extension\AbuseFilter;
use DeferredUpdates;
use ExtensionRegistry;
use InvalidArgumentException;
use ManualLogEntry;
use MediaWiki\CheckUser\Hooks;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\User\UserIdentityValue;
use Title;
use User;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
class AbuseLogger {
public const CONSTRUCTOR_OPTIONS = [
'AbuseFilterLogIP',
'AbuseFilterNotifications',
'AbuseFilterNotificationsPrivate',
];
/** @var Title */
private $title;
/** @var User */
private $user;
/** @var VariableHolder */
private $vars;
/** @var string */
private $action;
/** @var CentralDBManager */
private $centralDBManager;
/** @var FilterLookup */
private $filterLookup;
/** @var VariablesBlobStore */
private $varBlobStore;
/** @var VariablesManager */
private $varManager;
/** @var EditRevUpdater */
private $editRevUpdater;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var ServiceOptions */
private $options;
/** @var string */
private $wikiID;
/** @var string */
private $requestIP;
/**
* @param CentralDBManager $centralDBManager
* @param FilterLookup $filterLookup
* @param VariablesBlobStore $varBlobStore
* @param VariablesManager $varManager
* @param EditRevUpdater $editRevUpdater
* @param ILoadBalancer $loadBalancer
* @param ServiceOptions $options
* @param string $wikiID
* @param string $requestIP
* @param Title $title
* @param User $user
* @param VariableHolder $vars
*/
public function __construct(
CentralDBManager $centralDBManager,
FilterLookup $filterLookup,
VariablesBlobStore $varBlobStore,
VariablesManager $varManager,
EditRevUpdater $editRevUpdater,
ILoadBalancer $loadBalancer,
ServiceOptions $options,
string $wikiID,
string $requestIP,
Title $title,
User $user,
VariableHolder $vars
) {
if ( !$vars->varIsSet( 'action' ) ) {
throw new InvalidArgumentException( "The 'action' variable is not set." );
}
$this->centralDBManager = $centralDBManager;
$this->filterLookup = $filterLookup;
$this->varBlobStore = $varBlobStore;
$this->varManager = $varManager;
$this->editRevUpdater = $editRevUpdater;
$this->loadBalancer = $loadBalancer;
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->wikiID = $wikiID;
$this->requestIP = $requestIP;
$this->title = $title;
$this->user = $user;
$this->vars = $vars;
$this->action = $vars->getComputedVariable( 'action' )->toString();
}
/**
* Create and publish log entries for taken actions
*
* @param array[] $actionsTaken
* @return array Shape is [ 'local' => int[], 'global' => int[] ], IDs of logged filters
* @phan-return array{local:int[],global:int[]}
*/
public function addLogEntries( array $actionsTaken ): array {
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
$logTemplate = $this->buildLogTemplate();
$centralLogTemplate = [
'afl_wiki' => $this->wikiID,
];
$logRows = [];
$centralLogRows = [];
$loggedLocalFilters = [];
$loggedGlobalFilters = [];
foreach ( $actionsTaken as $filter => $actions ) {
list( $filterID, $global ) = GlobalNameUtils::splitGlobalName( $filter );
$thisLog = $logTemplate;
$thisLog['afl_filter_id'] = $filterID;
$thisLog['afl_global'] = (int)$global;
$thisLog['afl_actions'] = implode( ',', $actions );
// Don't log if we were only throttling.
// TODO This check should be removed or rewritten using Consequence objects
if ( $thisLog['afl_actions'] !== 'throttle' ) {
$logRows[] = $thisLog;
// Global logging
if ( $global ) {
$centralLog = $thisLog + $centralLogTemplate;
$centralLog['afl_filter_id'] = $filterID;
$centralLog['afl_global'] = 0;
$centralLog['afl_title'] = $this->title->getPrefixedText();
$centralLog['afl_namespace'] = 0;
$centralLogRows[] = $centralLog;
$loggedGlobalFilters[] = $filterID;
} else {
$loggedLocalFilters[] = $filterID;
}
}
}
if ( !count( $logRows ) ) {
return [ 'local' => [], 'global' => [] ];
}
$localLogIDs = $this->insertLocalLogEntries( $logRows, $dbw );
$globalLogIDs = [];
if ( count( $loggedGlobalFilters ) ) {
$fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
$globalLogIDs = $this->insertGlobalLogEntries( $centralLogRows, $fdb );
}
$this->editRevUpdater->setLogIdsForTarget(
$this->title,
[ 'local' => $localLogIDs, 'global' => $globalLogIDs ]
);
return [ 'local' => $loggedLocalFilters, 'global' => $loggedGlobalFilters ];
}
/**
* Creates a template to use for logging taken actions
*
* @return array
*/
private function buildLogTemplate(): array {
// If $this->user isn't safe to load (e.g. a failure during
// AbortAutoAccount), create a dummy anonymous user instead.
$user = $this->user->isSafeToLoad() ? $this->user : new User;
// Create a template
$logTemplate = [
'afl_user' => $user->getId(),
'afl_user_text' => $user->getName(),
'afl_timestamp' => $this->loadBalancer->getConnectionRef( DB_REPLICA )->timestamp(),
'afl_namespace' => $this->title->getNamespace(),
'afl_title' => $this->title->getDBkey(),
'afl_action' => $this->action,
'afl_ip' => $this->options->get( 'AbuseFilterLogIP' ) ? $this->requestIP : ''
];
// Hack to avoid revealing IPs of people creating accounts
if ( ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' ) && !$user->getId() ) {
$logTemplate['afl_user_text'] = $this->vars->getComputedVariable( 'accountname' )->toString();
}
return $logTemplate;
}
/**
* @param array $data
* @return ManualLogEntry
*/
private function newLocalLogEntryFromData( array $data ): ManualLogEntry {
// Give grep a chance to find the usages:
// logentry-abusefilter-hit
$entry = new ManualLogEntry( 'abusefilter', 'hit' );
$user = new UserIdentityValue( $data['afl_user'], $data['afl_user_text'] );
$entry->setPerformer( $user );
$entry->setTarget( $this->title );
$filterName = GlobalNameUtils::buildGlobalName(
$data['afl_filter_id'],
$data['afl_global'] === 1
);
// Additional info
$entry->setParameters( [
'action' => $data['afl_action'],
'filter' => $filterName,
'actions' => $data['afl_actions'],
'log' => $data['afl_id'],
] );
return $entry;
}
/**
* @param array[] $logRows
* @param IDatabase $dbw
* @return int[]
*/
private function insertLocalLogEntries( array $logRows, IDatabase $dbw ): array {
$varDump = $this->varBlobStore->storeVarDump( $this->vars );
$loggedIDs = [];
foreach ( $logRows as $data ) {
$data['afl_var_dump'] = $varDump;
$dbw->insert( 'abuse_filter_log', $data, __METHOD__ );
$loggedIDs[] = $data['afl_id'] = $dbw->insertId();
// Send data to CheckUser if installed and we
// aren't already sending a notification to recentchanges
if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
&& strpos( $this->options->get( 'AbuseFilterNotifications' ), 'rc' ) === false
) {
global $wgCheckUserLogAdditionalRights;
$wgCheckUserLogAdditionalRights[] = 'abusefilter-view';
$entry = $this->newLocalLogEntryFromData( $data );
$user = $entry->getPerformerIdentity();
// Invert the hack from ::buildLogTemplate because CheckUser attempts
// to assign an actor id to the non-existing user
if (
( $this->action === 'createaccount' || $this->action === 'autocreateaccount' )
&& !$user->getId()
) {
$entry->setPerformer( new UserIdentityValue( 0, $this->requestIP ) );
}
$rc = $entry->getRecentChange();
Hooks::updateCheckUserData( $rc );
}
if ( $this->options->get( 'AbuseFilterNotifications' ) !== false ) {
$filterID = $data['afl_filter_id'];
$global = $data['afl_global'];
if (
!$this->options->get( 'AbuseFilterNotificationsPrivate' ) &&
$this->filterLookup->getFilter( $filterID, $global )->isHidden()
) {
continue;
}
$entry = $this->newLocalLogEntryFromData( $data );
$this->publishEntry( $dbw, $entry );
}
}
return $loggedIDs;
}
/**
* @param array[] $centralLogRows
* @param IDatabase $fdb
* @return int[]
*/
private function insertGlobalLogEntries( array $centralLogRows, IDatabase $fdb ): array {
$this->varManager->computeDBVars( $this->vars );
$globalVarDump = $this->varBlobStore->storeVarDump( $this->vars, true );
foreach ( $centralLogRows as $index => $data ) {
$centralLogRows[$index]['afl_var_dump'] = $globalVarDump;
}
$loggedIDs = [];
foreach ( $centralLogRows as $row ) {
$fdb->insert( 'abuse_filter_log', $row, __METHOD__ );
$loggedIDs[] = $fdb->insertId();
}
return $loggedIDs;
}
/**
* Like ManualLogEntry::publish, but doesn't require an ID (which we don't have) and skips the
* tagging part
*
* @param IDatabase $dbw To cancel the callback if the log insertion fails
* @param ManualLogEntry $entry
*/
private function publishEntry( IDatabase $dbw, ManualLogEntry $entry ): void {
DeferredUpdates::addCallableUpdate(
function () use ( $entry ) {
$rc = $entry->getRecentChange();
$to = $this->options->get( 'AbuseFilterNotifications' );
if ( $to === 'rc' || $to === 'rcandudp' ) {
$rc->save( $rc::SEND_NONE );
}
if ( $to === 'udp' || $to === 'rcandudp' ) {
$rc->notifyRCFeeds();
}
},
DeferredUpdates::POSTSEND,
$dbw
);
}
}