Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.76% |
98 / 117 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
FilteredActionsHandler | |
83.76% |
98 / 117 |
|
40.00% |
4 / 10 |
31.36 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
onEditFilterMergedContent | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
3.33 | |||
filterEdit | |
80.00% |
20 / 25 |
|
0.00% |
0 / 1 |
7.39 | |||
getApiStatus | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
onTitleMove | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
onArticleDelete | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
onUploadVerifyUpload | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onUploadStashFile | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
filterUpload | |
66.67% |
14 / 21 |
|
0.00% |
0 / 1 |
7.33 | |||
onParserOutputStashForEdit | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers; |
4 | |
5 | use ApiMessage; |
6 | use Content; |
7 | use IBufferingStatsdDataFactory; |
8 | use IContextSource; |
9 | use MediaWiki\Deferred\DeferredUpdates; |
10 | use MediaWiki\Extension\AbuseFilter\BlockedDomainFilter; |
11 | use MediaWiki\Extension\AbuseFilter\EditRevUpdater; |
12 | use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory; |
13 | use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory; |
14 | use MediaWiki\Hook\EditFilterMergedContentHook; |
15 | use MediaWiki\Hook\TitleMoveHook; |
16 | use MediaWiki\Hook\UploadStashFileHook; |
17 | use MediaWiki\Hook\UploadVerifyUploadHook; |
18 | use MediaWiki\Logger\LoggerFactory; |
19 | use MediaWiki\Page\Hook\ArticleDeleteHook; |
20 | use MediaWiki\Permissions\PermissionManager; |
21 | use MediaWiki\Revision\SlotRecord; |
22 | use MediaWiki\Status\Status; |
23 | use MediaWiki\Storage\Hook\ParserOutputStashForEditHook; |
24 | use MediaWiki\Title\Title; |
25 | use MediaWiki\User\User; |
26 | use UploadBase; |
27 | use WikiPage; |
28 | |
29 | /** |
30 | * Handler for actions that can be filtered |
31 | */ |
32 | class FilteredActionsHandler implements |
33 | EditFilterMergedContentHook, |
34 | TitleMoveHook, |
35 | ArticleDeleteHook, |
36 | UploadVerifyUploadHook, |
37 | UploadStashFileHook, |
38 | ParserOutputStashForEditHook |
39 | { |
40 | /** @var IBufferingStatsdDataFactory */ |
41 | private $statsDataFactory; |
42 | /** @var FilterRunnerFactory */ |
43 | private $filterRunnerFactory; |
44 | /** @var VariableGeneratorFactory */ |
45 | private $variableGeneratorFactory; |
46 | /** @var EditRevUpdater */ |
47 | private $editRevUpdater; |
48 | private PermissionManager $permissionManager; |
49 | private BlockedDomainFilter $blockedDomainFilter; |
50 | |
51 | /** |
52 | * @param IBufferingStatsdDataFactory $statsDataFactory |
53 | * @param FilterRunnerFactory $filterRunnerFactory |
54 | * @param VariableGeneratorFactory $variableGeneratorFactory |
55 | * @param EditRevUpdater $editRevUpdater |
56 | * @param BlockedDomainFilter $blockedDomainFilter |
57 | * @param PermissionManager $permissionManager |
58 | */ |
59 | public function __construct( |
60 | IBufferingStatsdDataFactory $statsDataFactory, |
61 | FilterRunnerFactory $filterRunnerFactory, |
62 | VariableGeneratorFactory $variableGeneratorFactory, |
63 | EditRevUpdater $editRevUpdater, |
64 | BlockedDomainFilter $blockedDomainFilter, |
65 | PermissionManager $permissionManager |
66 | ) { |
67 | $this->statsDataFactory = $statsDataFactory; |
68 | $this->filterRunnerFactory = $filterRunnerFactory; |
69 | $this->variableGeneratorFactory = $variableGeneratorFactory; |
70 | $this->editRevUpdater = $editRevUpdater; |
71 | $this->blockedDomainFilter = $blockedDomainFilter; |
72 | $this->permissionManager = $permissionManager; |
73 | } |
74 | |
75 | /** |
76 | * @inheritDoc |
77 | * @param string $slot Slot role for the content, added by Wikibase (T288885) |
78 | */ |
79 | public function onEditFilterMergedContent( |
80 | IContextSource $context, |
81 | Content $content, |
82 | Status $status, |
83 | $summary, |
84 | User $user, |
85 | $minoredit, |
86 | string $slot = SlotRecord::MAIN |
87 | ) { |
88 | $startTime = microtime( true ); |
89 | if ( !$status->isOK() ) { |
90 | // Investigate what happens if we skip filtering here (T211680) |
91 | LoggerFactory::getInstance( 'AbuseFilter' )->info( |
92 | 'Status is already not OK', |
93 | [ 'status' => (string)$status ] |
94 | ); |
95 | } |
96 | |
97 | $filterResult = $this->filterEdit( $context, $user, $content, $summary, $slot ); |
98 | |
99 | if ( !$filterResult->isOK() ) { |
100 | // Produce a useful error message for API edits |
101 | $filterResultApi = self::getApiStatus( $filterResult ); |
102 | $status->merge( $filterResultApi ); |
103 | } |
104 | $this->statsDataFactory->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime ); |
105 | |
106 | return $status->isOK(); |
107 | } |
108 | |
109 | /** |
110 | * Implementation for EditFilterMergedContent hook. |
111 | * |
112 | * @param IContextSource $context the context of the edit |
113 | * @param User $user |
114 | * @param Content $content the new Content generated by the edit |
115 | * @param string $summary Edit summary for page |
116 | * @param string $slot slot role for the content |
117 | * @return Status |
118 | */ |
119 | private function filterEdit( |
120 | IContextSource $context, |
121 | User $user, |
122 | Content $content, |
123 | string $summary, |
124 | string $slot = SlotRecord::MAIN |
125 | ): Status { |
126 | $this->editRevUpdater->clearLastEditPage(); |
127 | |
128 | $title = $context->getTitle(); |
129 | $logger = LoggerFactory::getInstance( 'AbuseFilter' ); |
130 | if ( $title === null ) { |
131 | // T144265: This *should* never happen. |
132 | $logger->warning( __METHOD__ . ' received a null title.' ); |
133 | return Status::newGood(); |
134 | } |
135 | if ( !$title->canExist() ) { |
136 | // This also should be handled in EditPage or whoever is calling the hook. |
137 | $logger->warning( __METHOD__ . ' received a Title that cannot exist.' ); |
138 | // Note that if the title cannot exist, there's no much point in filtering the edit anyway |
139 | return Status::newGood(); |
140 | } |
141 | |
142 | $page = $context->getWikiPage(); |
143 | |
144 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title ); |
145 | $vars = $builder->getEditVars( $content, $summary, $slot, $page ); |
146 | if ( $vars === null ) { |
147 | // We don't have to filter the edit |
148 | return Status::newGood(); |
149 | } |
150 | $runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' ); |
151 | $filterResult = $runner->run(); |
152 | if ( !$filterResult->isOK() ) { |
153 | return $filterResult; |
154 | } |
155 | |
156 | $this->editRevUpdater->setLastEditPage( $page ); |
157 | |
158 | if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) { |
159 | return Status::newGood(); |
160 | } |
161 | $blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title ); |
162 | if ( !$blockedDomainFilterResult->isOK() ) { |
163 | return $blockedDomainFilterResult; |
164 | } |
165 | |
166 | return Status::newGood(); |
167 | } |
168 | |
169 | /** |
170 | * @param Status $status Error message details |
171 | * @return Status Status containing the same error messages with extra data for the API |
172 | */ |
173 | private static function getApiStatus( Status $status ): Status { |
174 | $allActionsTaken = $status->getValue(); |
175 | $statusForApi = Status::newGood(); |
176 | |
177 | foreach ( $status->getErrors() as $error ) { |
178 | [ $filterDescription, $filter ] = $error['params']; |
179 | $actionsTaken = $allActionsTaken[ $filter ]; |
180 | |
181 | $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed'; |
182 | $data = [ |
183 | 'abusefilter' => [ |
184 | 'id' => $filter, |
185 | 'description' => $filterDescription, |
186 | 'actions' => $actionsTaken, |
187 | ], |
188 | ]; |
189 | |
190 | $message = ApiMessage::create( $error, $code, $data ); |
191 | $statusForApi->fatal( $message ); |
192 | } |
193 | |
194 | return $statusForApi; |
195 | } |
196 | |
197 | /** |
198 | * @inheritDoc |
199 | */ |
200 | public function onTitleMove( Title $old, Title $nt, User $user, $reason, Status &$status ) { |
201 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $old ); |
202 | $vars = $builder->getMoveVars( $nt, $reason ); |
203 | $runner = $this->filterRunnerFactory->newRunner( $user, $old, $vars, 'default' ); |
204 | $result = $runner->run(); |
205 | $status->merge( $result ); |
206 | } |
207 | |
208 | /** |
209 | * @inheritDoc |
210 | */ |
211 | public function onArticleDelete( WikiPage $wikiPage, User $user, &$reason, &$error, Status &$status, $suppress ) { |
212 | if ( $suppress ) { |
213 | // Don't filter suppressions, T71617 |
214 | return true; |
215 | } |
216 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $wikiPage->getTitle() ); |
217 | $vars = $builder->getDeleteVars( $reason ); |
218 | $runner = $this->filterRunnerFactory->newRunner( $user, $wikiPage->getTitle(), $vars, 'default' ); |
219 | $filterResult = $runner->run(); |
220 | |
221 | $status->merge( $filterResult ); |
222 | $error = $filterResult->isOK() ? '' : $filterResult->getHTML(); |
223 | |
224 | return $filterResult->isOK(); |
225 | } |
226 | |
227 | /** |
228 | * @inheritDoc |
229 | */ |
230 | public function onUploadVerifyUpload( |
231 | UploadBase $upload, |
232 | User $user, |
233 | ?array $props, |
234 | $comment, |
235 | $pageText, |
236 | &$error |
237 | ) { |
238 | return $this->filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error ); |
239 | } |
240 | |
241 | /** |
242 | * Filter an upload to stash. If a filter doesn't need to check the page contents or |
243 | * upload comment, it can use `action='stashupload'` to provide better experience to e.g. |
244 | * UploadWizard (rejecting files immediately, rather than after the user adds the details). |
245 | * |
246 | * @inheritDoc |
247 | */ |
248 | public function onUploadStashFile( UploadBase $upload, User $user, ?array $props, &$error ) { |
249 | return $this->filterUpload( 'stashupload', $upload, $user, $props, null, null, $error ); |
250 | } |
251 | |
252 | /** |
253 | * Implementation for UploadStashFile and UploadVerifyUpload hooks. |
254 | * |
255 | * @param string $action 'upload' or 'stashupload' |
256 | * @param UploadBase $upload |
257 | * @param User $user User performing the action |
258 | * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath(). |
259 | * @param string|null $summary Upload log comment (also used as edit summary) |
260 | * @param string|null $text File description page text (only used for new uploads) |
261 | * @param array|ApiMessage &$error |
262 | * @return bool |
263 | */ |
264 | private function filterUpload( |
265 | string $action, |
266 | UploadBase $upload, |
267 | User $user, |
268 | ?array $props, |
269 | ?string $summary, |
270 | ?string $text, |
271 | &$error |
272 | ): bool { |
273 | $title = $upload->getTitle(); |
274 | if ( $title === null ) { |
275 | // T144265: This could happen for 'stashupload' if the specified title is invalid. |
276 | // Let UploadBase warn the user about that, and we'll filter later. |
277 | $logger = LoggerFactory::getInstance( 'AbuseFilter' ); |
278 | $logger->warning( __METHOD__ . " received a null title. Action: $action." ); |
279 | return true; |
280 | } |
281 | |
282 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title ); |
283 | $vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props ); |
284 | if ( $vars === null ) { |
285 | return true; |
286 | } |
287 | $runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' ); |
288 | $filterResult = $runner->run(); |
289 | |
290 | if ( !$filterResult->isOK() ) { |
291 | // Produce a useful error message for API edits |
292 | $filterResultApi = self::getApiStatus( $filterResult ); |
293 | // @todo Return all errors instead of only the first one |
294 | $error = $filterResultApi->getErrors()[0]['message']; |
295 | } else { |
296 | if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) { |
297 | return true; |
298 | } |
299 | $blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title ); |
300 | if ( !$blockedDomainFilterResult->isOK() ) { |
301 | $error = $blockedDomainFilterResult->getErrors()[0]['message']; |
302 | return $blockedDomainFilterResult->isOK(); |
303 | } |
304 | } |
305 | |
306 | return $filterResult->isOK(); |
307 | } |
308 | |
309 | /** |
310 | * @inheritDoc |
311 | */ |
312 | public function onParserOutputStashForEdit( $page, $content, $output, $summary, $user ) { |
313 | // XXX: This makes the assumption that this method is only ever called for the main slot. |
314 | // Which right now holds true, but any more fancy MCR stuff will likely break here... |
315 | $slot = SlotRecord::MAIN; |
316 | |
317 | // Cache any resulting filter matches. |
318 | // Do this outside the synchronous stash lock to avoid any chance of slowdown. |
319 | DeferredUpdates::addCallableUpdate( |
320 | function () use ( |
321 | $user, |
322 | $page, |
323 | $summary, |
324 | $content, |
325 | $slot |
326 | ) { |
327 | $startTime = microtime( true ); |
328 | $generator = $this->variableGeneratorFactory->newRunGenerator( $user, $page->getTitle() ); |
329 | $vars = $generator->getStashEditVars( $content, $summary, $slot, $page ); |
330 | if ( !$vars ) { |
331 | return; |
332 | } |
333 | $runner = $this->filterRunnerFactory->newRunner( $user, $page->getTitle(), $vars, 'default' ); |
334 | $runner->runForStash(); |
335 | $totalTime = microtime( true ) - $startTime; |
336 | $this->statsDataFactory->timing( 'timing.stashAbuseFilter', $totalTime ); |
337 | }, |
338 | DeferredUpdates::PRESEND |
339 | ); |
340 | } |
341 | } |