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