Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
145 / 145
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
FilterValidator
100.00% covered (success)
100.00%
145 / 145
100.00% covered (success)
100.00%
12 / 12
60
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 checkAll
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 checkValidSyntax
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 checkRequiredFields
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 checkConflictingFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 checkAllTags
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 checkEmptyMessages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 checkThrottleParameters
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
13
 checkGlobalFilterEditPermission
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 checkMessagesOnGlobalFilters
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 checkRestrictedActions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 checkGroup
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator;
7use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
8use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
9use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
10use MediaWiki\Permissions\Authority;
11use MediaWiki\Status\Status;
12use Message;
13
14/**
15 * This class validates filters, e.g. before saving.
16 */
17class FilterValidator {
18    public const SERVICE_NAME = 'AbuseFilterFilterValidator';
19
20    public const CONSTRUCTOR_OPTIONS = [
21        'AbuseFilterValidGroups',
22        'AbuseFilterActionRestrictions'
23    ];
24
25    /** @var ChangeTagValidator */
26    private $changeTagValidator;
27
28    /** @var RuleCheckerFactory */
29    private $ruleCheckerFactory;
30
31    /** @var AbuseFilterPermissionManager */
32    private $permManager;
33
34    /** @var string[] */
35    private $restrictedActions;
36
37    /** @var string[] */
38    private $validGroups;
39
40    /**
41     * @param ChangeTagValidator $changeTagValidator
42     * @param RuleCheckerFactory $ruleCheckerFactory
43     * @param AbuseFilterPermissionManager $permManager
44     * @param ServiceOptions $options
45     */
46    public function __construct(
47        ChangeTagValidator $changeTagValidator,
48        RuleCheckerFactory $ruleCheckerFactory,
49        AbuseFilterPermissionManager $permManager,
50        ServiceOptions $options
51    ) {
52        $this->changeTagValidator = $changeTagValidator;
53        $this->ruleCheckerFactory = $ruleCheckerFactory;
54        $this->permManager = $permManager;
55        $this->restrictedActions = array_keys( array_filter( $options->get( 'AbuseFilterActionRestrictions' ) ) );
56        $this->validGroups = $options->get( 'AbuseFilterValidGroups' );
57    }
58
59    /**
60     * @param AbstractFilter $newFilter
61     * @param AbstractFilter $originalFilter
62     * @param Authority $performer
63     * @return Status
64     */
65    public function checkAll(
66        AbstractFilter $newFilter, AbstractFilter $originalFilter, Authority $performer
67    ): Status {
68        // TODO We might consider not bailing at the first error, so we can show all errors at the first attempt
69
70        $syntaxStatus = $this->checkValidSyntax( $newFilter );
71        if ( !$syntaxStatus->isGood() ) {
72            return $syntaxStatus;
73        }
74
75        $requiredFieldsStatus = $this->checkRequiredFields( $newFilter );
76        if ( !$requiredFieldsStatus->isGood() ) {
77            return $requiredFieldsStatus;
78        }
79
80        $conflictStatus = $this->checkConflictingFields( $newFilter );
81        if ( !$conflictStatus->isGood() ) {
82            return $conflictStatus;
83        }
84
85        $actions = $newFilter->getActions();
86        if ( isset( $actions['tag'] ) ) {
87            $validTagsStatus = $this->checkAllTags( $actions['tag'] );
88            if ( !$validTagsStatus->isGood() ) {
89                return $validTagsStatus;
90            }
91        }
92
93        $messagesStatus = $this->checkEmptyMessages( $newFilter );
94        if ( !$messagesStatus->isGood() ) {
95            return $messagesStatus;
96        }
97
98        if ( isset( $actions['throttle'] ) ) {
99            $throttleStatus = $this->checkThrottleParameters( $actions['throttle'] );
100            if ( !$throttleStatus->isGood() ) {
101                return $throttleStatus;
102            }
103        }
104
105        $globalPermStatus = $this->checkGlobalFilterEditPermission( $performer, $newFilter, $originalFilter );
106        if ( !$globalPermStatus->isGood() ) {
107            return $globalPermStatus;
108        }
109
110        $globalFilterMsgStatus = $this->checkMessagesOnGlobalFilters( $newFilter );
111        if ( !$globalFilterMsgStatus->isGood() ) {
112            return $globalFilterMsgStatus;
113        }
114
115        $restrictedActionsStatus = $this->checkRestrictedActions( $performer, $newFilter, $originalFilter );
116        if ( !$restrictedActionsStatus->isGood() ) {
117            return $restrictedActionsStatus;
118        }
119
120        $filterGroupStatus = $this->checkGroup( $newFilter );
121        if ( !$filterGroupStatus->isGood() ) {
122            return $filterGroupStatus;
123        }
124
125        return Status::newGood();
126    }
127
128    /**
129     * @param AbstractFilter $filter
130     * @return Status
131     */
132    public function checkValidSyntax( AbstractFilter $filter ): Status {
133        $ret = Status::newGood();
134        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
135        $syntaxStatus = $ruleChecker->checkSyntax( $filter->getRules() );
136        if ( !$syntaxStatus->isValid() ) {
137            $excep = $syntaxStatus->getException();
138            $errMsg = $excep instanceof UserVisibleException
139                ? $excep->getMessageObj()
140                : $excep->getMessage();
141            $ret->error( 'abusefilter-edit-badsyntax', $errMsg );
142        }
143        return $ret;
144    }
145
146    /**
147     * @param AbstractFilter $filter
148     * @return Status
149     */
150    public function checkRequiredFields( AbstractFilter $filter ): Status {
151        $ret = Status::newGood();
152        $missing = [];
153        if ( $filter->getRules() === '' ) {
154            $missing[] = new Message( 'abusefilter-edit-field-conditions' );
155        }
156        if ( trim( $filter->getName() ) === '' ) {
157            $missing[] = new Message( 'abusefilter-edit-field-description' );
158        }
159        if ( count( $missing ) !== 0 ) {
160            $ret->error(
161                'abusefilter-edit-missingfields',
162                Message::listParam( $missing, 'comma' )
163            );
164        }
165        return $ret;
166    }
167
168    /**
169     * @param AbstractFilter $filter
170     * @return Status
171     */
172    public function checkConflictingFields( AbstractFilter $filter ): Status {
173        $ret = Status::newGood();
174        // Don't allow setting as deleted an active filter
175        if ( $filter->isEnabled() && $filter->isDeleted() ) {
176            $ret->error( 'abusefilter-edit-deleting-enabled' );
177        }
178        return $ret;
179    }
180
181    /**
182     * @param string[] $tags
183     * @return Status
184     */
185    public function checkAllTags( array $tags ): Status {
186        $ret = Status::newGood();
187        if ( count( $tags ) === 0 ) {
188            $ret->error( 'tags-create-no-name' );
189            return $ret;
190        }
191        foreach ( $tags as $tag ) {
192            $curStatus = $this->changeTagValidator->validateTag( $tag );
193
194            if ( !$curStatus->isGood() ) {
195                // TODO Consider merging
196                return $curStatus;
197            }
198        }
199        return $ret;
200    }
201
202    /**
203     * @todo Consider merging with checkRequiredFields
204     * @param AbstractFilter $filter
205     * @return Status
206     */
207    public function checkEmptyMessages( AbstractFilter $filter ): Status {
208        $ret = Status::newGood();
209        $actions = $filter->getActions();
210        // TODO: Check and report both together
211        if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) {
212            $ret->error( 'abusefilter-edit-invalid-warn-message' );
213        } elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) {
214            $ret->error( 'abusefilter-edit-invalid-disallow-message' );
215        }
216        return $ret;
217    }
218
219    /**
220     * Validate throttle parameters
221     *
222     * @param array $params Throttle parameters
223     * @return Status
224     */
225    public function checkThrottleParameters( array $params ): Status {
226        [ $throttleCount, $throttlePeriod ] = explode( ',', $params[1], 2 );
227        $throttleGroups = array_slice( $params, 2 );
228        $validGroups = [
229            'ip',
230            'user',
231            'range',
232            'creationdate',
233            'editcount',
234            'site',
235            'page'
236        ];
237
238        $ret = Status::newGood();
239        if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) {
240            $ret->error( 'abusefilter-edit-invalid-throttlecount' );
241        } elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) {
242            $ret->error( 'abusefilter-edit-invalid-throttleperiod' );
243        } elseif ( !$throttleGroups ) {
244            $ret->error( 'abusefilter-edit-empty-throttlegroups' );
245        } else {
246            $valid = true;
247            // Groups should be unique in three ways: no direct duplicates like 'user' and 'user',
248            // no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates
249            // within subgroups ('user,ip,user')
250            $uniqueGroups = [];
251            $uniqueSubGroups = true;
252            // Every group should be valid, and subgroups should have valid groups inside
253            foreach ( $throttleGroups as $group ) {
254                if ( strpos( $group, ',' ) !== false ) {
255                    $subGroups = explode( ',', $group );
256                    // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
257                    if ( $subGroups !== array_unique( $subGroups ) ) {
258                        $uniqueSubGroups = false;
259                        break;
260                    }
261                    foreach ( $subGroups as $subGroup ) {
262                        if ( !in_array( $subGroup, $validGroups ) ) {
263                            $valid = false;
264                            break 2;
265                        }
266                    }
267                    sort( $subGroups );
268                    $uniqueGroups[] = implode( ',', $subGroups );
269                } else {
270                    if ( !in_array( $group, $validGroups ) ) {
271                        $valid = false;
272                        break;
273                    }
274                    $uniqueGroups[] = $group;
275                }
276            }
277
278            if ( !$valid ) {
279                $ret->error( 'abusefilter-edit-invalid-throttlegroups' );
280            } elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) {
281                $ret->error( 'abusefilter-edit-duplicated-throttlegroups' );
282            }
283        }
284
285        return $ret;
286    }
287
288    /**
289     * @param Authority $performer
290     * @param AbstractFilter $newFilter
291     * @param AbstractFilter $originalFilter
292     * @return Status
293     */
294    public function checkGlobalFilterEditPermission(
295        Authority $performer,
296        AbstractFilter $newFilter,
297        AbstractFilter $originalFilter
298    ): Status {
299        if (
300            !$this->permManager->canEditFilter( $performer, $newFilter ) ||
301            !$this->permManager->canEditFilter( $performer, $originalFilter )
302        ) {
303            return Status::newFatal( 'abusefilter-edit-notallowed-global' );
304        }
305        return Status::newGood();
306    }
307
308    /**
309     * @param AbstractFilter $filter
310     * @return Status
311     */
312    public function checkMessagesOnGlobalFilters( AbstractFilter $filter ): Status {
313        $ret = Status::newGood();
314        $actions = $filter->getActions();
315        if (
316            $filter->isGlobal() && (
317                ( isset( $actions['warn'] ) && $actions['warn'][0] !== 'abusefilter-warning' ) ||
318                ( isset( $actions['disallow'] ) && $actions['disallow'][0] !== 'abusefilter-disallowed' )
319            )
320        ) {
321            $ret->error( 'abusefilter-edit-notallowed-global-custom-msg' );
322        }
323        return $ret;
324    }
325
326    /**
327     * @param Authority $performer
328     * @param AbstractFilter $newFilter
329     * @param AbstractFilter $originalFilter
330     * @return Status
331     */
332    public function checkRestrictedActions(
333        Authority $performer,
334        AbstractFilter $newFilter,
335        AbstractFilter $originalFilter
336    ): Status {
337        $ret = Status::newGood();
338        $allEnabledActions = $newFilter->getActions() + $originalFilter->getActions();
339        if (
340            array_intersect_key( array_fill_keys( $this->restrictedActions, true ), $allEnabledActions )
341            && !$this->permManager->canEditFilterWithRestrictedActions( $performer )
342        ) {
343            $ret->error( 'abusefilter-edit-restricted' );
344        }
345        return $ret;
346    }
347
348    /**
349     * @param AbstractFilter $filter
350     * @return Status
351     */
352    public function checkGroup( AbstractFilter $filter ): Status {
353        $ret = Status::newGood();
354        $group = $filter->getGroup();
355        if ( !in_array( $group, $this->validGroups, true ) ) {
356            $ret->error( 'abusefilter-edit-invalid-group' );
357        }
358        return $ret;
359    }
360}