mediawiki-extensions-AbuseF.../includes/AbuseLogger.php
Dreamy Jazz f3c87749b8 Send AbuseFilter logs to CheckUser on PRESEND
Why:
* AbuseFilter can send AbuseFilter logs to CheckUser if they are
  not being sent to Special:RecentChanges.
* However, if this action is indirectly causing the creation of
  an account (such as through temporary account auto-creation),
  the log entry is sent to CheckUser before the temporary account
  actually exists in the 'user' table.
* This causes a CannotCreateActorException, as the performer does
  not exist on the wiki just yet and therefore cannot have an
  actor ID until the temporary account is created.
* This exception can happen if the AbuseFilter filter only creates
  a log entry and does not prevent the edit, so would not be
  necessarily fixed by T334623.
* Sending the logs to CheckUser on PRESEND avoids this, as the
  user will exist by the time that PRESEND is run but still allows
  any failures to cause an exception which can be seen by the user.

What:
* Wrap the call to Hooks::updateCheckUserData in AbuseLogger
  ::insertLocalLogEntries in a DeferredUpdate which is set to run
  on PRESEND.

Bug: T358632
Change-Id: Ia615fce3e26b88d5457ecc01231044b326b79973
2024-02-28 13:09:22 +00:00

321 lines
9.8 KiB
PHP

<?php
namespace MediaWiki\Extension\AbuseFilter;
use ExtensionRegistry;
use InvalidArgumentException;
use ManualLogEntry;
use MediaWiki\CheckUser\Hooks;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;
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 LBFactory */
private $lbFactory;
/** @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 LBFactory $lbFactory
* @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,
LBFactory $lbFactory,
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->lbFactory = $lbFactory;
$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->lbFactory->getPrimaryDatabase();
$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->lbFactory->getReplicaDatabase()->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();
// We need to send the entries on PRESEND to ensure that the user definitely exists.
// A temporary account being created through an edit will not exist until after AbuseFilter
// processes the edit and attempts to log to CheckUser.
DeferredUpdates::addCallableUpdate( static function () use ( $rc ) {
Hooks::updateCheckUserData( $rc );
}, DeferredUpdates::PRESEND );
}
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
);
}
}