Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.48% |
96 / 115 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
FilteredActionsHandler | |
83.48% |
96 / 115 |
|
40.00% |
4 / 10 |
30.29 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
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 | |||
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 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\ArticleDeleteHook; |
19 | use MediaWiki\Permissions\PermissionManager; |
20 | use MediaWiki\Revision\SlotRecord; |
21 | use MediaWiki\Status\Status; |
22 | use MediaWiki\Storage\Hook\ParserOutputStashForEditHook; |
23 | use MediaWiki\Title\Title; |
24 | use MediaWiki\User\User; |
25 | use UploadBase; |
26 | use Wikimedia\Stats\IBufferingStatsdDataFactory; |
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 | $this->filterEdit( $context, $user, $content, $summary, $slot, $status ); |
98 | |
99 | $this->statsDataFactory->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime ); |
100 | |
101 | return $status->isOK(); |
102 | } |
103 | |
104 | /** |
105 | * Implementation for EditFilterMergedContent hook. |
106 | * |
107 | * @param IContextSource $context the context of the edit |
108 | * @param User $user |
109 | * @param Content $content the new Content generated by the edit |
110 | * @param string $summary Edit summary for page |
111 | * @param string $slot slot role for the content |
112 | * @param Status $status |
113 | */ |
114 | private function filterEdit( |
115 | IContextSource $context, |
116 | User $user, |
117 | Content $content, |
118 | string $summary, |
119 | string $slot, |
120 | Status $status |
121 | ): void { |
122 | $this->editRevUpdater->clearLastEditPage(); |
123 | |
124 | $title = $context->getTitle(); |
125 | $logger = LoggerFactory::getInstance( 'AbuseFilter' ); |
126 | if ( $title === null ) { |
127 | // T144265: This *should* never happen. |
128 | $logger->warning( __METHOD__ . ' received a null title.' ); |
129 | return; |
130 | } |
131 | if ( !$title->canExist() ) { |
132 | // This also should be handled in EditPage or whoever is calling the hook. |
133 | $logger->warning( __METHOD__ . ' received a Title that cannot exist.' ); |
134 | // Note that if the title cannot exist, there's no much point in filtering the edit anyway |
135 | return; |
136 | } |
137 | |
138 | $page = $context->getWikiPage(); |
139 | |
140 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title ); |
141 | $vars = $builder->getEditVars( $content, $summary, $slot, $page ); |
142 | if ( $vars === null ) { |
143 | // We don't have to filter the edit |
144 | return; |
145 | } |
146 | $runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' ); |
147 | $filterResult = $runner->run(); |
148 | if ( !$filterResult->isOK() ) { |
149 | // Produce a useful error message for API edits |
150 | $filterResultApi = self::getApiStatus( $filterResult ); |
151 | $status->merge( $filterResultApi ); |
152 | return; |
153 | } |
154 | |
155 | $this->editRevUpdater->setLastEditPage( $page ); |
156 | |
157 | if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) { |
158 | return; |
159 | } |
160 | $blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title ); |
161 | if ( !$blockedDomainFilterResult->isOK() ) { |
162 | $status->merge( $blockedDomainFilterResult ); |
163 | } |
164 | } |
165 | |
166 | /** |
167 | * @param Status $status Error message details |
168 | * @return Status Status containing the same error messages with extra data for the API |
169 | */ |
170 | private static function getApiStatus( Status $status ): Status { |
171 | $allActionsTaken = $status->getValue(); |
172 | $statusForApi = Status::newGood(); |
173 | |
174 | foreach ( $status->getMessages() as $msg ) { |
175 | [ $filterDescription, $filter ] = $msg->getParams(); |
176 | $actionsTaken = $allActionsTaken[ $filter ]; |
177 | |
178 | $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed'; |
179 | $data = [ |
180 | 'abusefilter' => [ |
181 | 'id' => $filter, |
182 | 'description' => $filterDescription, |
183 | 'actions' => $actionsTaken, |
184 | ], |
185 | ]; |
186 | |
187 | $message = ApiMessage::create( $msg, $code, $data ); |
188 | $statusForApi->fatal( $message ); |
189 | } |
190 | |
191 | return $statusForApi; |
192 | } |
193 | |
194 | /** |
195 | * @inheritDoc |
196 | */ |
197 | public function onTitleMove( Title $old, Title $nt, User $user, $reason, Status &$status ) { |
198 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $old ); |
199 | $vars = $builder->getMoveVars( $nt, $reason ); |
200 | $runner = $this->filterRunnerFactory->newRunner( $user, $old, $vars, 'default' ); |
201 | $result = $runner->run(); |
202 | $status->merge( $result ); |
203 | } |
204 | |
205 | /** |
206 | * @inheritDoc |
207 | */ |
208 | public function onArticleDelete( WikiPage $wikiPage, User $user, &$reason, &$error, Status &$status, $suppress ) { |
209 | if ( $suppress ) { |
210 | // Don't filter suppressions, T71617 |
211 | return true; |
212 | } |
213 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $wikiPage->getTitle() ); |
214 | $vars = $builder->getDeleteVars( $reason ); |
215 | $runner = $this->filterRunnerFactory->newRunner( $user, $wikiPage->getTitle(), $vars, 'default' ); |
216 | $filterResult = $runner->run(); |
217 | |
218 | $status->merge( $filterResult ); |
219 | $error = $filterResult->isOK() ? '' : $filterResult->getHTML(); |
220 | |
221 | return $filterResult->isOK(); |
222 | } |
223 | |
224 | /** |
225 | * @inheritDoc |
226 | */ |
227 | public function onUploadVerifyUpload( |
228 | UploadBase $upload, |
229 | User $user, |
230 | ?array $props, |
231 | $comment, |
232 | $pageText, |
233 | &$error |
234 | ) { |
235 | return $this->filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error ); |
236 | } |
237 | |
238 | /** |
239 | * Filter an upload to stash. If a filter doesn't need to check the page contents or |
240 | * upload comment, it can use `action='stashupload'` to provide better experience to e.g. |
241 | * UploadWizard (rejecting files immediately, rather than after the user adds the details). |
242 | * |
243 | * @inheritDoc |
244 | */ |
245 | public function onUploadStashFile( UploadBase $upload, User $user, ?array $props, &$error ) { |
246 | return $this->filterUpload( 'stashupload', $upload, $user, $props, null, null, $error ); |
247 | } |
248 | |
249 | /** |
250 | * Implementation for UploadStashFile and UploadVerifyUpload hooks. |
251 | * |
252 | * @param string $action 'upload' or 'stashupload' |
253 | * @param UploadBase $upload |
254 | * @param User $user User performing the action |
255 | * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath(). |
256 | * @param string|null $summary Upload log comment (also used as edit summary) |
257 | * @param string|null $text File description page text (only used for new uploads) |
258 | * @param array|ApiMessage &$error |
259 | * @return bool |
260 | */ |
261 | private function filterUpload( |
262 | string $action, |
263 | UploadBase $upload, |
264 | User $user, |
265 | ?array $props, |
266 | ?string $summary, |
267 | ?string $text, |
268 | &$error |
269 | ): bool { |
270 | $title = $upload->getTitle(); |
271 | if ( $title === null ) { |
272 | // T144265: This could happen for 'stashupload' if the specified title is invalid. |
273 | // Let UploadBase warn the user about that, and we'll filter later. |
274 | $logger = LoggerFactory::getInstance( 'AbuseFilter' ); |
275 | $logger->warning( __METHOD__ . " received a null title. Action: $action." ); |
276 | return true; |
277 | } |
278 | |
279 | $builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title ); |
280 | $vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props ); |
281 | if ( $vars === null ) { |
282 | return true; |
283 | } |
284 | $runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' ); |
285 | $filterResult = $runner->run(); |
286 | |
287 | if ( !$filterResult->isOK() ) { |
288 | // Produce a useful error message for API edits |
289 | $filterResultApi = self::getApiStatus( $filterResult ); |
290 | // @todo Return all errors instead of only the first one |
291 | $error = $filterResultApi->getMessages()[0]; |
292 | } else { |
293 | if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) { |
294 | return true; |
295 | } |
296 | $blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title ); |
297 | if ( !$blockedDomainFilterResult->isOK() ) { |
298 | $error = $blockedDomainFilterResult->getMessages()[0]; |
299 | return $blockedDomainFilterResult->isOK(); |
300 | } |
301 | } |
302 | |
303 | return $filterResult->isOK(); |
304 | } |
305 | |
306 | /** |
307 | * @inheritDoc |
308 | */ |
309 | public function onParserOutputStashForEdit( $page, $content, $output, $summary, $user ) { |
310 | // XXX: This makes the assumption that this method is only ever called for the main slot. |
311 | // Which right now holds true, but any more fancy MCR stuff will likely break here... |
312 | $slot = SlotRecord::MAIN; |
313 | |
314 | // Cache any resulting filter matches. |
315 | // Do this outside the synchronous stash lock to avoid any chance of slowdown. |
316 | DeferredUpdates::addCallableUpdate( |
317 | function () use ( |
318 | $user, |
319 | $page, |
320 | $summary, |
321 | $content, |
322 | $slot |
323 | ) { |
324 | $startTime = microtime( true ); |
325 | $generator = $this->variableGeneratorFactory->newRunGenerator( $user, $page->getTitle() ); |
326 | $vars = $generator->getStashEditVars( $content, $summary, $slot, $page ); |
327 | if ( !$vars ) { |
328 | return; |
329 | } |
330 | $runner = $this->filterRunnerFactory->newRunner( $user, $page->getTitle(), $vars, 'default' ); |
331 | $runner->runForStash(); |
332 | $totalTime = microtime( true ) - $startTime; |
333 | $this->statsDataFactory->timing( 'timing.stashAbuseFilter', $totalTime ); |
334 | }, |
335 | DeferredUpdates::PRESEND |
336 | ); |
337 | } |
338 | } |