2020-10-10 17:20:21 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
namespace MediaWiki\Extension\AbuseFilter;
|
|
|
|
|
|
|
|
use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
|
|
|
|
use MediaWiki\Extension\AbuseFilter\Parser\ParserFactory;
|
|
|
|
use MediaWiki\User\UserIdentity;
|
|
|
|
use Message;
|
|
|
|
use Status;
|
|
|
|
use User;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* This class validates filters, e.g. before saving.
|
|
|
|
*/
|
|
|
|
class FilterValidator {
|
|
|
|
public const SERVICE_NAME = 'AbuseFilterFilterValidator';
|
|
|
|
|
2020-11-06 12:13:02 +00:00
|
|
|
/** @var ChangeTagValidator */
|
|
|
|
private $changeTagValidator;
|
2020-10-10 17:20:21 +00:00
|
|
|
|
|
|
|
/** @var ParserFactory */
|
|
|
|
private $parserFactory;
|
|
|
|
|
|
|
|
/** @var AbuseFilterPermissionManager */
|
|
|
|
private $permManager;
|
|
|
|
|
|
|
|
/** @var string[] */
|
|
|
|
private $restrictedActions;
|
|
|
|
|
|
|
|
/**
|
2020-11-06 12:13:02 +00:00
|
|
|
* @param ChangeTagValidator $changeTagValidator
|
2020-10-10 17:20:21 +00:00
|
|
|
* @param ParserFactory $parserFactory
|
|
|
|
* @param AbuseFilterPermissionManager $permManager
|
|
|
|
* @param string[] $restrictedActions
|
|
|
|
*/
|
|
|
|
public function __construct(
|
2020-11-06 12:13:02 +00:00
|
|
|
ChangeTagValidator $changeTagValidator,
|
2020-10-10 17:20:21 +00:00
|
|
|
ParserFactory $parserFactory,
|
|
|
|
AbuseFilterPermissionManager $permManager,
|
|
|
|
array $restrictedActions
|
|
|
|
) {
|
2020-11-06 12:13:02 +00:00
|
|
|
$this->changeTagValidator = $changeTagValidator;
|
2020-10-10 17:20:21 +00:00
|
|
|
$this->parserFactory = $parserFactory;
|
|
|
|
$this->permManager = $permManager;
|
|
|
|
$this->restrictedActions = $restrictedActions;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param AbstractFilter $newFilter
|
|
|
|
* @param AbstractFilter $originalFilter
|
|
|
|
* @param User $user
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkAll( AbstractFilter $newFilter, AbstractFilter $originalFilter, User $user ) : Status {
|
|
|
|
// TODO We might consider not bailing at the first error, so we can show all errors at the first attempt
|
|
|
|
|
|
|
|
$syntaxStatus = $this->checkValidSyntax( $newFilter );
|
|
|
|
if ( !$syntaxStatus->isGood() ) {
|
|
|
|
return $syntaxStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
$requiredFieldsStatus = $this->checkRequiredFields( $newFilter );
|
|
|
|
if ( !$requiredFieldsStatus->isGood() ) {
|
|
|
|
return $requiredFieldsStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
$conflictStatus = $this->checkConflictingFields( $newFilter );
|
|
|
|
if ( !$conflictStatus->isGood() ) {
|
|
|
|
return $conflictStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
$actions = $newFilter->getActions();
|
|
|
|
if ( isset( $actions['tag'] ) ) {
|
|
|
|
$validTagsStatus = $this->checkAllTags( $actions['tag'] );
|
|
|
|
if ( !$validTagsStatus->isGood() ) {
|
|
|
|
return $validTagsStatus;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$messagesStatus = $this->checkEmptyMessages( $newFilter );
|
|
|
|
if ( !$messagesStatus->isGood() ) {
|
|
|
|
return $messagesStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( isset( $actions['throttle'] ) ) {
|
|
|
|
$throttleStatus = $this->checkThrottleParameters( $actions['throttle'] );
|
|
|
|
if ( !$throttleStatus->isGood() ) {
|
|
|
|
return $throttleStatus;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$globalPermStatus = $this->checkGlobalFilterEditPermission( $user, $newFilter, $originalFilter );
|
|
|
|
if ( !$globalPermStatus->isGood() ) {
|
|
|
|
return $globalPermStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
$globalFilterMsgStatus = $this->checkMessagesOnGlobalFilters( $newFilter );
|
|
|
|
if ( !$globalFilterMsgStatus->isGood() ) {
|
|
|
|
return $globalFilterMsgStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
$restrictedActionsStatus = $this->checkRestrictedActions( $user, $newFilter, $originalFilter );
|
|
|
|
if ( !$restrictedActionsStatus->isGood() ) {
|
|
|
|
return $restrictedActionsStatus;
|
|
|
|
}
|
|
|
|
|
|
|
|
return Status::newGood();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param AbstractFilter $filter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkValidSyntax( AbstractFilter $filter ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
$parser = $this->parserFactory->newParser();
|
|
|
|
$syntaxerr = $parser->checkSyntax( $filter->getRules() );
|
|
|
|
if ( $syntaxerr !== true ) {
|
|
|
|
$ret->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] );
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param AbstractFilter $filter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkRequiredFields( AbstractFilter $filter ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
$missing = [];
|
|
|
|
if ( $filter->getRules() === '' ) {
|
|
|
|
$missing[] = new Message( 'abusefilter-edit-field-conditions' );
|
|
|
|
}
|
|
|
|
if ( trim( $filter->getName() ) === '' ) {
|
|
|
|
$missing[] = new Message( 'abusefilter-edit-field-description' );
|
|
|
|
}
|
|
|
|
if ( count( $missing ) !== 0 ) {
|
|
|
|
$ret->error(
|
|
|
|
'abusefilter-edit-missingfields',
|
|
|
|
Message::listParam( $missing, 'comma' )
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param AbstractFilter $filter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkConflictingFields( AbstractFilter $filter ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
// Don't allow setting as deleted an active filter
|
|
|
|
if ( $filter->isEnabled() && $filter->isDeleted() ) {
|
|
|
|
$ret->error( 'abusefilter-edit-deleting-enabled' );
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param string[] $tags
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkAllTags( array $tags ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
if ( count( $tags ) === 0 ) {
|
|
|
|
$ret->error( 'tags-create-no-name' );
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
foreach ( $tags as $tag ) {
|
2020-11-06 12:13:02 +00:00
|
|
|
$curStatus = $this->changeTagValidator->validateTag( $tag );
|
2020-10-10 17:20:21 +00:00
|
|
|
|
|
|
|
if ( !$curStatus->isGood() ) {
|
|
|
|
// TODO Consider merging
|
|
|
|
return $curStatus;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @todo Consider merging with checkRequiredFields
|
|
|
|
* @param AbstractFilter $filter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkEmptyMessages( AbstractFilter $filter ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
$actions = $filter->getActions();
|
|
|
|
// TODO: Check and report both together
|
|
|
|
if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) {
|
|
|
|
$ret->error( 'abusefilter-edit-invalid-warn-message' );
|
|
|
|
} elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) {
|
|
|
|
$ret->error( 'abusefilter-edit-invalid-disallow-message' );
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Validate throttle parameters
|
|
|
|
*
|
|
|
|
* @param array $params Throttle parameters
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkThrottleParameters( array $params ) : Status {
|
|
|
|
list( $throttleCount, $throttlePeriod ) = explode( ',', $params[1], 2 );
|
|
|
|
$throttleGroups = array_slice( $params, 2 );
|
|
|
|
$validGroups = [
|
|
|
|
'ip',
|
|
|
|
'user',
|
|
|
|
'range',
|
|
|
|
'creationdate',
|
|
|
|
'editcount',
|
|
|
|
'site',
|
|
|
|
'page'
|
|
|
|
];
|
|
|
|
|
|
|
|
$ret = Status::newGood();
|
|
|
|
if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) {
|
|
|
|
$ret->error( 'abusefilter-edit-invalid-throttlecount' );
|
|
|
|
} elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) {
|
|
|
|
$ret->error( 'abusefilter-edit-invalid-throttleperiod' );
|
|
|
|
} elseif ( !$throttleGroups ) {
|
|
|
|
$ret->error( 'abusefilter-edit-empty-throttlegroups' );
|
|
|
|
} else {
|
|
|
|
$valid = true;
|
|
|
|
// Groups should be unique in three ways: no direct duplicates like 'user' and 'user',
|
|
|
|
// no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates
|
|
|
|
// within subgroups ('user,ip,user')
|
|
|
|
$uniqueGroups = [];
|
|
|
|
$uniqueSubGroups = true;
|
|
|
|
// Every group should be valid, and subgroups should have valid groups inside
|
|
|
|
foreach ( $throttleGroups as $group ) {
|
|
|
|
if ( strpos( $group, ',' ) !== false ) {
|
|
|
|
$subGroups = explode( ',', $group );
|
|
|
|
if ( $subGroups !== array_unique( $subGroups ) ) {
|
|
|
|
$uniqueSubGroups = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
foreach ( $subGroups as $subGroup ) {
|
|
|
|
if ( !in_array( $subGroup, $validGroups ) ) {
|
|
|
|
$valid = false;
|
|
|
|
break 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
sort( $subGroups );
|
|
|
|
$uniqueGroups[] = implode( ',', $subGroups );
|
|
|
|
} else {
|
|
|
|
if ( !in_array( $group, $validGroups ) ) {
|
|
|
|
$valid = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
$uniqueGroups[] = $group;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( !$valid ) {
|
|
|
|
$ret->error( 'abusefilter-edit-invalid-throttlegroups' );
|
|
|
|
} elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) {
|
|
|
|
$ret->error( 'abusefilter-edit-duplicated-throttlegroups' );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param User $user
|
|
|
|
* @param AbstractFilter $newFilter
|
|
|
|
* @param AbstractFilter $originalFilter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkGlobalFilterEditPermission(
|
|
|
|
User $user,
|
|
|
|
AbstractFilter $newFilter,
|
|
|
|
AbstractFilter $originalFilter
|
|
|
|
) : Status {
|
|
|
|
if (
|
|
|
|
!$this->permManager->canEditFilter( $user, $newFilter ) ||
|
|
|
|
!$this->permManager->canEditFilter( $user, $originalFilter )
|
|
|
|
) {
|
|
|
|
return Status::newFatal( 'abusefilter-edit-notallowed-global' );
|
|
|
|
}
|
|
|
|
return Status::newGood();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param AbstractFilter $filter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkMessagesOnGlobalFilters( AbstractFilter $filter ) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
$actions = $filter->getActions();
|
|
|
|
if (
|
|
|
|
$filter->isGlobal() && (
|
|
|
|
( isset( $actions['warn'] ) && $actions['warn'][0] !== 'abusefilter-warning' ) ||
|
|
|
|
( isset( $actions['disallow'] ) && $actions['disallow'][0] !== 'abusefilter-disallowed' )
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
$ret->error( 'abusefilter-edit-notallowed-global-custom-msg' );
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param UserIdentity $user
|
|
|
|
* @param AbstractFilter $newFilter
|
|
|
|
* @param AbstractFilter $originalFilter
|
|
|
|
* @return Status
|
|
|
|
*/
|
|
|
|
public function checkRestrictedActions(
|
|
|
|
UserIdentity $user,
|
|
|
|
AbstractFilter $newFilter,
|
|
|
|
AbstractFilter $originalFilter
|
|
|
|
) : Status {
|
|
|
|
$ret = Status::newGood();
|
|
|
|
$allEnabledActions = array_merge( $newFilter->getActions(), $originalFilter->getActions() );
|
|
|
|
if (
|
|
|
|
array_intersect_key( array_flip( $this->restrictedActions ), $allEnabledActions )
|
|
|
|
&& !$this->permManager->canEditFilterWithRestrictedActions( $user )
|
|
|
|
) {
|
|
|
|
$ret->error( 'abusefilter-edit-restricted' );
|
|
|
|
}
|
|
|
|
return $ret;
|
|
|
|
}
|
|
|
|
}
|