Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.84% |
170 / 172 |
|
92.86% |
13 / 14 |
CRAP | |
0.00% |
0 / 1 |
FilterValidator | |
98.84% |
170 / 172 |
|
92.86% |
13 / 14 |
69 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
checkAll | |
95.00% |
38 / 40 |
|
0.00% |
0 / 1 |
15 | |||
checkValidSyntax | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
checkRequiredFields | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
checkConflictingFields | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
checkAllTags | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
checkEmptyMessages | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
checkThrottleParameters | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
13 | |||
checkGlobalFilterEditPermission | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
checkMessagesOnGlobalFilters | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
checkRestrictedActions | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
checkProtectedVariables | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
checkCanViewProtectedVariables | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
checkGroup | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter; |
4 | |
5 | use MediaWiki\Config\ServiceOptions; |
6 | use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator; |
7 | use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter; |
8 | use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException; |
9 | use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory; |
10 | use MediaWiki\Message\Message; |
11 | use MediaWiki\Permissions\Authority; |
12 | use MediaWiki\Status\Status; |
13 | |
14 | /** |
15 | * This class validates filters, e.g. before saving. |
16 | */ |
17 | class 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 | } |