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