Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
145 / 145 |
|
100.00% |
12 / 12 |
CRAP | |
100.00% |
1 / 1 |
FilterValidator | |
100.00% |
145 / 145 |
|
100.00% |
12 / 12 |
60 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
checkAll | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
13 | |||
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 | |||
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\Permissions\Authority; |
11 | use Message; |
12 | use 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 | ]; |
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 | list( $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 | } |