Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.76% covered (warning)
83.76%
98 / 117
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilteredActionsHandler
83.76% covered (warning)
83.76%
98 / 117
40.00% covered (danger)
40.00%
4 / 10
31.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 onEditFilterMergedContent
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
3.33
 filterEdit
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
7.39
 getApiStatus
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 onTitleMove
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 onArticleDelete
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 onUploadVerifyUpload
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onUploadStashFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filterUpload
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
7.33
 onParserOutputStashForEdit
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;
4
5use ApiMessage;
6use Content;
7use IBufferingStatsdDataFactory;
8use IContextSource;
9use MediaWiki\Deferred\DeferredUpdates;
10use MediaWiki\Extension\AbuseFilter\BlockedDomainFilter;
11use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
12use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory;
13use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
14use MediaWiki\Hook\EditFilterMergedContentHook;
15use MediaWiki\Hook\TitleMoveHook;
16use MediaWiki\Hook\UploadStashFileHook;
17use MediaWiki\Hook\UploadVerifyUploadHook;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\Page\Hook\ArticleDeleteHook;
20use MediaWiki\Permissions\PermissionManager;
21use MediaWiki\Revision\SlotRecord;
22use MediaWiki\Status\Status;
23use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
24use MediaWiki\Title\Title;
25use MediaWiki\User\User;
26use UploadBase;
27use WikiPage;
28
29/**
30 * Handler for actions that can be filtered
31 */
32class 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}