Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.48% covered (warning)
83.48%
96 / 115
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilteredActionsHandler
83.48% covered (warning)
83.48%
96 / 115
40.00% covered (danger)
40.00%
4 / 10
30.29
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
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
2.35
 filterEdit
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
7.35
 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 MediaWiki\Context\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        $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}