Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.90% covered (warning)
83.90%
99 / 118
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilteredActionsHandler
83.90% covered (warning)
83.90%
99 / 118
40.00% covered (danger)
40.00%
4 / 10
28.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
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
 onPageDelete
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 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 MediaWiki\Api\ApiMessage;
6use MediaWiki\Content\Content;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Extension\AbuseFilter\BlockedDomainFilter;
10use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
11use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory;
12use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
13use MediaWiki\Hook\EditFilterMergedContentHook;
14use MediaWiki\Hook\TitleMoveHook;
15use MediaWiki\Hook\UploadStashFileHook;
16use MediaWiki\Hook\UploadVerifyUploadHook;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\Page\Hook\PageDeleteHook;
19use MediaWiki\Page\ProperPageIdentity;
20use MediaWiki\Permissions\Authority;
21use MediaWiki\Permissions\PermissionManager;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Status\Status;
24use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
25use MediaWiki\Title\Title;
26use MediaWiki\Title\TitleFactory;
27use MediaWiki\User\User;
28use MediaWiki\User\UserFactory;
29use StatusValue;
30use UploadBase;
31use Wikimedia\Stats\IBufferingStatsdDataFactory;
32
33/**
34 * Handler for actions that can be filtered
35 */
36class 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}