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