Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
170 / 172
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterValidator
98.84% covered (success)
98.84%
170 / 172
92.86% covered (success)
92.86%
13 / 14
69
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 checkAll
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
15
 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
 checkProtectedVariables
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 checkCanViewProtectedVariables
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 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\Message\Message;
11use MediaWiki\Permissions\Authority;
12use MediaWiki\Status\Status;
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        'AbuseFilterProtectedVariables',
24    ];
25
26    /** @var ChangeTagValidator */
27    private $changeTagValidator;
28
29    /** @var RuleCheckerFactory */
30    private $ruleCheckerFactory;
31
32    /** @var AbuseFilterPermissionManager */
33    private $permManager;
34
35    /** @var string[] */
36    private $restrictedActions;
37
38    /** @var string[] */
39    private $validGroups;
40
41    /**
42     * @var string[] Protected variables defined in config via AbuseFilterProtectedVariables
43     */
44    private $protectedVariables;
45
46    /**
47     * @param ChangeTagValidator $changeTagValidator
48     * @param RuleCheckerFactory $ruleCheckerFactory
49     * @param AbuseFilterPermissionManager $permManager
50     * @param ServiceOptions $options
51     */
52    public function __construct(
53        ChangeTagValidator $changeTagValidator,
54        RuleCheckerFactory $ruleCheckerFactory,
55        AbuseFilterPermissionManager $permManager,
56        ServiceOptions $options
57    ) {
58        $this->changeTagValidator = $changeTagValidator;
59        $this->ruleCheckerFactory = $ruleCheckerFactory;
60        $this->permManager = $permManager;
61        $this->restrictedActions = array_keys( array_filter( $options->get( 'AbuseFilterActionRestrictions' ) ) );
62        $this->validGroups = $options->get( 'AbuseFilterValidGroups' );
63        $this->protectedVariables = $options->get( 'AbuseFilterProtectedVariables' );
64    }
65
66    /**
67     * @param AbstractFilter $newFilter
68     * @param AbstractFilter $originalFilter
69     * @param Authority $performer
70     * @return Status
71     */
72    public function checkAll(
73        AbstractFilter $newFilter, AbstractFilter $originalFilter, Authority $performer
74    ): Status {
75        // TODO We might consider not bailing at the first error, so we can show all errors at the first attempt
76
77        $syntaxStatus = $this->checkValidSyntax( $newFilter );
78        if ( !$syntaxStatus->isGood() ) {
79            return $syntaxStatus;
80        }
81
82        $requiredFieldsStatus = $this->checkRequiredFields( $newFilter );
83        if ( !$requiredFieldsStatus->isGood() ) {
84            return $requiredFieldsStatus;
85        }
86
87        $conflictStatus = $this->checkConflictingFields( $newFilter );
88        if ( !$conflictStatus->isGood() ) {
89            return $conflictStatus;
90        }
91
92        $actions = $newFilter->getActions();
93        if ( isset( $actions['tag'] ) ) {
94            $validTagsStatus = $this->checkAllTags( $actions['tag'] );
95            if ( !$validTagsStatus->isGood() ) {
96                return $validTagsStatus;
97            }
98        }
99
100        $messagesStatus = $this->checkEmptyMessages( $newFilter );
101        if ( !$messagesStatus->isGood() ) {
102            return $messagesStatus;
103        }
104
105        if ( isset( $actions['throttle'] ) ) {
106            $throttleStatus = $this->checkThrottleParameters( $actions['throttle'] );
107            if ( !$throttleStatus->isGood() ) {
108                return $throttleStatus;
109            }
110        }
111
112        $protectedVarsPermissionStatus = $this->checkCanViewProtectedVariables( $performer, $newFilter );
113        if ( !$protectedVarsPermissionStatus->isGood() ) {
114            return $protectedVarsPermissionStatus;
115        }
116
117        $protectedVarsStatus = $this->checkProtectedVariables( $newFilter, $originalFilter );
118        if ( !$protectedVarsStatus->isGood() ) {
119            return $protectedVarsStatus;
120        }
121
122        $globalPermStatus = $this->checkGlobalFilterEditPermission( $performer, $newFilter, $originalFilter );
123        if ( !$globalPermStatus->isGood() ) {
124            return $globalPermStatus;
125        }
126
127        $globalFilterMsgStatus = $this->checkMessagesOnGlobalFilters( $newFilter );
128        if ( !$globalFilterMsgStatus->isGood() ) {
129            return $globalFilterMsgStatus;
130        }
131
132        $restrictedActionsStatus = $this->checkRestrictedActions( $performer, $newFilter, $originalFilter );
133        if ( !$restrictedActionsStatus->isGood() ) {
134            return $restrictedActionsStatus;
135        }
136
137        $filterGroupStatus = $this->checkGroup( $newFilter );
138        if ( !$filterGroupStatus->isGood() ) {
139            return $filterGroupStatus;
140        }
141
142        return Status::newGood();
143    }
144
145    /**
146     * @param AbstractFilter $filter
147     * @return Status
148     */
149    public function checkValidSyntax( AbstractFilter $filter ): Status {
150        $ret = Status::newGood();
151        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
152        $syntaxStatus = $ruleChecker->checkSyntax( $filter->getRules() );
153        if ( !$syntaxStatus->isValid() ) {
154            $excep = $syntaxStatus->getException();
155            $errMsg = $excep instanceof UserVisibleException
156                ? $excep->getMessageObj()
157                : $excep->getMessage();
158            $ret->error( 'abusefilter-edit-badsyntax', $errMsg );
159        }
160        return $ret;
161    }
162
163    /**
164     * @param AbstractFilter $filter
165     * @return Status
166     */
167    public function checkRequiredFields( AbstractFilter $filter ): Status {
168        $ret = Status::newGood();
169        $missing = [];
170        if ( $filter->getRules() === '' ) {
171            $missing[] = new Message( 'abusefilter-edit-field-conditions' );
172        }
173        if ( trim( $filter->getName() ) === '' ) {
174            $missing[] = new Message( 'abusefilter-edit-field-description' );
175        }
176        if ( count( $missing ) !== 0 ) {
177            $ret->error(
178                'abusefilter-edit-missingfields',
179                Message::listParam( $missing, 'comma' )
180            );
181        }
182        return $ret;
183    }
184
185    /**
186     * @param AbstractFilter $filter
187     * @return Status
188     */
189    public function checkConflictingFields( AbstractFilter $filter ): Status {
190        $ret = Status::newGood();
191        // Don't allow setting as deleted an active filter
192        if ( $filter->isEnabled() && $filter->isDeleted() ) {
193            $ret->error( 'abusefilter-edit-deleting-enabled' );
194        }
195        return $ret;
196    }
197
198    /**
199     * @param string[] $tags
200     * @return Status
201     */
202    public function checkAllTags( array $tags ): Status {
203        $ret = Status::newGood();
204        if ( count( $tags ) === 0 ) {
205            $ret->error( 'tags-create-no-name' );
206            return $ret;
207        }
208        foreach ( $tags as $tag ) {
209            $curStatus = $this->changeTagValidator->validateTag( $tag );
210
211            if ( !$curStatus->isGood() ) {
212                // TODO Consider merging
213                return $curStatus;
214            }
215        }
216        return $ret;
217    }
218
219    /**
220     * @todo Consider merging with checkRequiredFields
221     * @param AbstractFilter $filter
222     * @return Status
223     */
224    public function checkEmptyMessages( AbstractFilter $filter ): Status {
225        $ret = Status::newGood();
226        $actions = $filter->getActions();
227        // TODO: Check and report both together
228        if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) {
229            $ret->error( 'abusefilter-edit-invalid-warn-message' );
230        } elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) {
231            $ret->error( 'abusefilter-edit-invalid-disallow-message' );
232        }
233        return $ret;
234    }
235
236    /**
237     * Validate throttle parameters
238     *
239     * @param array $params Throttle parameters
240     * @return Status
241     */
242    public function checkThrottleParameters( array $params ): Status {
243        [ $throttleCount, $throttlePeriod ] = explode( ',', $params[1], 2 );
244        $throttleGroups = array_slice( $params, 2 );
245        $validGroups = [
246            'ip',
247            'user',
248            'range',
249            'creationdate',
250            'editcount',
251            'site',
252            'page'
253        ];
254
255        $ret = Status::newGood();
256        if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) {
257            $ret->error( 'abusefilter-edit-invalid-throttlecount' );
258        } elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) {
259            $ret->error( 'abusefilter-edit-invalid-throttleperiod' );
260        } elseif ( !$throttleGroups ) {
261            $ret->error( 'abusefilter-edit-empty-throttlegroups' );
262        } else {
263            $valid = true;
264            // Groups should be unique in three ways: no direct duplicates like 'user' and 'user',
265            // no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates
266            // within subgroups ('user,ip,user')
267            $uniqueGroups = [];
268            $uniqueSubGroups = true;
269            // Every group should be valid, and subgroups should have valid groups inside
270            foreach ( $throttleGroups as $group ) {
271                if ( strpos( $group, ',' ) !== false ) {
272                    $subGroups = explode( ',', $group );
273                    // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
274                    if ( $subGroups !== array_unique( $subGroups ) ) {
275                        $uniqueSubGroups = false;
276                        break;
277                    }
278                    foreach ( $subGroups as $subGroup ) {
279                        if ( !in_array( $subGroup, $validGroups ) ) {
280                            $valid = false;
281                            break 2;
282                        }
283                    }
284                    sort( $subGroups );
285                    $uniqueGroups[] = implode( ',', $subGroups );
286                } else {
287                    if ( !in_array( $group, $validGroups ) ) {
288                        $valid = false;
289                        break;
290                    }
291                    $uniqueGroups[] = $group;
292                }
293            }
294
295            if ( !$valid ) {
296                $ret->error( 'abusefilter-edit-invalid-throttlegroups' );
297            } elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) {
298                $ret->error( 'abusefilter-edit-duplicated-throttlegroups' );
299            }
300        }
301
302        return $ret;
303    }
304
305    /**
306     * @param Authority $performer
307     * @param AbstractFilter $newFilter
308     * @param AbstractFilter $originalFilter
309     * @return Status
310     */
311    public function checkGlobalFilterEditPermission(
312        Authority $performer,
313        AbstractFilter $newFilter,
314        AbstractFilter $originalFilter
315    ): Status {
316        if (
317            !$this->permManager->canEditFilter( $performer, $newFilter ) ||
318            !$this->permManager->canEditFilter( $performer, $originalFilter )
319        ) {
320            return Status::newFatal( 'abusefilter-edit-notallowed-global' );
321        }
322        return Status::newGood();
323    }
324
325    /**
326     * @param AbstractFilter $filter
327     * @return Status
328     */
329    public function checkMessagesOnGlobalFilters( AbstractFilter $filter ): Status {
330        $ret = Status::newGood();
331        $actions = $filter->getActions();
332        if (
333            $filter->isGlobal() && (
334                ( isset( $actions['warn'] ) && $actions['warn'][0] !== 'abusefilter-warning' ) ||
335                ( isset( $actions['disallow'] ) && $actions['disallow'][0] !== 'abusefilter-disallowed' )
336            )
337        ) {
338            $ret->error( 'abusefilter-edit-notallowed-global-custom-msg' );
339        }
340        return $ret;
341    }
342
343    /**
344     * @param Authority $performer
345     * @param AbstractFilter $newFilter
346     * @param AbstractFilter $originalFilter
347     * @return Status
348     */
349    public function checkRestrictedActions(
350        Authority $performer,
351        AbstractFilter $newFilter,
352        AbstractFilter $originalFilter
353    ): Status {
354        $ret = Status::newGood();
355        $allEnabledActions = $newFilter->getActions() + $originalFilter->getActions();
356        if (
357            array_intersect_key( array_fill_keys( $this->restrictedActions, true ), $allEnabledActions )
358            && !$this->permManager->canEditFilterWithRestrictedActions( $performer )
359        ) {
360            $ret->error( 'abusefilter-edit-restricted' );
361        }
362        return $ret;
363    }
364
365    /**
366     * @param AbstractFilter $filter
367     * @param ?AbstractFilter $originalFilter
368     * @return Status
369     */
370    public function checkProtectedVariables( AbstractFilter $filter, ?AbstractFilter $originalFilter = null ): Status {
371        $ret = Status::newGood();
372
373        // If an original filter is passed through, check if it's already protected and bypass this check
374        // if so.
375        // T364485 introduces a UX that disables the checkbox for already protected filters and
376        // therefore $filter will always fail the isProtected check but because it's already protected,
377        // FilterStore->filterToDatabaseRow() will ensure it stays protected
378        if ( $originalFilter && $originalFilter->isProtected() ) {
379            return $ret;
380        }
381
382        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
383        $usedVariables = $ruleChecker->getUsedVars( $filter->getRules() );
384        $usedProtectedVariables = array_intersect( $usedVariables, $this->protectedVariables );
385
386        if (
387            count( $usedProtectedVariables ) > 0 &&
388            !$filter->isProtected()
389        ) {
390            $ret->error(
391                'abusefilter-edit-protected-variable-not-protected',
392                Message::listParam( $usedProtectedVariables )
393            );
394        }
395
396        return $ret;
397    }
398
399    /**
400     * @param Authority $performer
401     * @param AbstractFilter $filter
402     * @return Status
403     */
404    public function checkCanViewProtectedVariables( Authority $performer, AbstractFilter $filter ): Status {
405        $ret = Status::newGood();
406        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
407        $usedVars = $ruleChecker->getUsedVars( $filter->getRules() );
408        $forbiddenVariables = $this->permManager->getForbiddenVariables( $performer, $usedVars );
409        if ( $forbiddenVariables ) {
410            $ret->error( 'abusefilter-edit-protected-variable', Message::listParam( $forbiddenVariables ) );
411        }
412        return $ret;
413    }
414
415    /**
416     * @param AbstractFilter $filter
417     * @return Status
418     */
419    public function checkGroup( AbstractFilter $filter ): Status {
420        $ret = Status::newGood();
421        $group = $filter->getGroup();
422        if ( !in_array( $group, $this->validGroups, true ) ) {
423            $ret->error( 'abusefilter-edit-invalid-group' );
424        }
425        return $ret;
426    }
427}