Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
83.19% |
94 / 113 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
| FilteredActionsHandler | |
83.19% |
94 / 113 |
|
40.00% |
4 / 10 |
31.73 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
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 | |||
| onPageDelete | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
| 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.65% |
22 / 23 |
|
0.00% |
0 / 1 |
4 | |||
| 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\BlockedDomains\IBlockedDomainFilter; |
| 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\PageDeleteHook; |
| 19 | use MediaWiki\Page\ProperPageIdentity; |
| 20 | use MediaWiki\Permissions\Authority; |
| 21 | use MediaWiki\Permissions\PermissionManager; |
| 22 | use MediaWiki\Revision\SlotRecord; |
| 23 | use MediaWiki\Status\Status; |
| 24 | use MediaWiki\Storage\Hook\ParserOutputStashForEditHook; |
| 25 | use MediaWiki\Title\Title; |
| 26 | use MediaWiki\Title\TitleFactory; |
| 27 | use MediaWiki\User\TempUser\TempUserConfig; |
| 28 | use MediaWiki\User\User; |
| 29 | use MediaWiki\User\UserFactory; |
| 30 | use StatusValue; |
| 31 | use UploadBase; |
| 32 | use Wikimedia\Stats\IBufferingStatsdDataFactory; |
| 33 | |
| 34 | /** |
| 35 | * Handler for actions that can be filtered |
| 36 | */ |
| 37 | class 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 | } |