mediawiki-extensions-AbuseF.../includes/FilterValidator.php
Daimona Eaytoy 7c1d1c6d7d Return warnings from the parser, add warning for catch-all regexps
This commit introduces some boilerplate for emitting warnings from the
AbuseFilter parser, and also code for showing these warnings in the ace
editor. Adding new warnings should be as simple as appending to
AbuseFilterParser::warnings (and adding the relevant i18n).

Bug: T264768
Bug: T269770
Change-Id: Ic11021b379f997a89f59c8c0572338d957e089a6
2020-12-18 18:22:41 +01:00

331 lines
9.2 KiB
PHP

<?php
namespace MediaWiki\Extension\AbuseFilter;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator;
use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
use MediaWiki\Extension\AbuseFilter\Parser\AFPUserVisibleException;
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';
/** @var ChangeTagValidator */
private $changeTagValidator;
/** @var ParserFactory */
private $parserFactory;
/** @var AbuseFilterPermissionManager */
private $permManager;
/** @var string[] */
private $restrictedActions;
/**
* @param ChangeTagValidator $changeTagValidator
* @param ParserFactory $parserFactory
* @param AbuseFilterPermissionManager $permManager
* @param string[] $restrictedActions
*/
public function __construct(
ChangeTagValidator $changeTagValidator,
ParserFactory $parserFactory,
AbuseFilterPermissionManager $permManager,
array $restrictedActions
) {
$this->changeTagValidator = $changeTagValidator;
$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();
$syntaxStatus = $parser->checkSyntax( $filter->getRules() );
if ( $syntaxStatus->getResult() !== true ) {
$excep = $syntaxStatus->getException();
$errMsg = $excep instanceof AFPUserVisibleException
? $excep->getMessageObj()->text()
: $excep->getMessage();
$ret->error( 'abusefilter-edit-badsyntax', $errMsg );
}
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 ) {
$curStatus = $this->changeTagValidator->validateTag( $tag );
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;
}
}