diff --git a/repo/rest-api/src/Application/UseCases/UpdateExceptionHandler.php b/repo/rest-api/src/Application/UseCases/UpdateExceptionHandler.php index dbf9ab8687..df2b13cfe1 100644 --- a/repo/rest-api/src/Application/UseCases/UpdateExceptionHandler.php +++ b/repo/rest-api/src/Application/UseCases/UpdateExceptionHandler.php @@ -2,6 +2,7 @@ namespace Wikibase\Repo\RestApi\Application\UseCases; +use Wikibase\Repo\RestApi\Domain\Services\Exceptions\AbuseFilterException; use Wikibase\Repo\RestApi\Domain\Services\Exceptions\ResourceTooLargeException; /** @@ -24,6 +25,11 @@ public function executeWithExceptionHandling( callable $callback ) { "Edit resulted in a resource that exceeds the size limit of $maxSizeInKb kB", [ UseCaseError::CONTEXT_LIMIT => $maxSizeInKb ] ); + } catch ( AbuseFilterException $e ) { + throw UseCaseError::newPermissionDenied( UseCaseError::PERMISSION_DENIED_REASON_ABUSE_FILTER, [ + 'filter_id' => $e->getFilterId(), + 'filter_description' => $e->getFilterDescription(), + ] ); } } diff --git a/repo/rest-api/src/Application/UseCases/UseCaseError.php b/repo/rest-api/src/Application/UseCases/UseCaseError.php index a5a7daa8fa..7a4aedab8e 100644 --- a/repo/rest-api/src/Application/UseCases/UseCaseError.php +++ b/repo/rest-api/src/Application/UseCases/UseCaseError.php @@ -33,6 +33,7 @@ class UseCaseError extends UseCaseException { public const PERMISSION_DENIED_REASON_UNAUTHORIZED_BOT_EDIT = 'unauthorized-bot-edit'; public const PERMISSION_DENIED_REASON_PAGE_PROTECTED = 'resource-protected'; public const PERMISSION_DENIED_REASON_USER_BLOCKED = 'blocked-user'; + public const PERMISSION_DENIED_REASON_ABUSE_FILTER = 'abuse-filter'; public const PERMISSION_DENIED_UNKNOWN_REASON = 'permission-denied-unknown-reason'; public const POLICY_VIOLATION_ITEM_LABEL_DESCRIPTION_DUPLICATE = 'item-label-description-duplicate'; public const POLICY_VIOLATION_PROPERTY_LABEL_DUPLICATE = 'property-label-duplicate'; diff --git a/repo/rest-api/src/Domain/Services/Exceptions/AbuseFilterException.php b/repo/rest-api/src/Domain/Services/Exceptions/AbuseFilterException.php new file mode 100644 index 0000000000..7423a827fb --- /dev/null +++ b/repo/rest-api/src/Domain/Services/Exceptions/AbuseFilterException.php @@ -0,0 +1,36 @@ +filterId = $filterId; + $this->filterDescription = $filterDescription; + } + + public function getFilterId(): int { + return $this->filterId; + } + + public function getFilterDescription(): string { + return $this->filterDescription; + } + +} diff --git a/repo/rest-api/src/Infrastructure/DataAccess/EntityUpdater.php b/repo/rest-api/src/Infrastructure/DataAccess/EntityUpdater.php index 007f494216..1bca12c461 100644 --- a/repo/rest-api/src/Infrastructure/DataAccess/EntityUpdater.php +++ b/repo/rest-api/src/Infrastructure/DataAccess/EntityUpdater.php @@ -17,6 +17,7 @@ use Wikibase\Lib\Store\EntityStore; use Wikibase\Repo\EditEntity\MediaWikiEditEntityFactory; use Wikibase\Repo\RestApi\Domain\Model\EditMetadata; +use Wikibase\Repo\RestApi\Domain\Services\Exceptions\AbuseFilterException; use Wikibase\Repo\RestApi\Domain\Services\Exceptions\ResourceTooLargeException; use Wikibase\Repo\RestApi\Infrastructure\DataAccess\Exceptions\EntityUpdateFailed; use Wikibase\Repo\RestApi\Infrastructure\DataAccess\Exceptions\EntityUpdatePrevented; @@ -70,6 +71,7 @@ public function update( EntityDocument $entity, EditMetadata $editMetadata ): En /** * @throws EntityUpdateFailed * @throws ResourceTooLargeException + * @throws AbuseFilterException */ private function createOrUpdate( EntityDocument $entity, @@ -105,6 +107,12 @@ private function createOrUpdate( throw new ResourceTooLargeException( $maxSizeInKiloBytes ); } + $abuseFilterError = $this->findErrorInStatus( $status, 'abusefilter' ); + if ( $abuseFilterError ) { + [ $filterDescription, $filterId ] = $abuseFilterError->getParams(); + throw new AbuseFilterException( (int)$filterId, $filterDescription ); + } + if ( $this->isPreventedEdit( $status ) ) { throw new EntityUpdatePrevented( (string)$status ); } @@ -119,8 +127,7 @@ private function createOrUpdate( private function isPreventedEdit( Status $status ): bool { return $this->findErrorInStatus( $status, 'actionthrottledtext' ) - || $this->findErrorInStatus( $status, 'spam-blacklisted' ) - || $this->findErrorInStatus( $status, 'abusefilter' ); + || $this->findErrorInStatus( $status, 'spam-blacklisted' ); } private function findErrorInStatus( Status $status, string $errorCode ): ?MessageSpecifier { diff --git a/repo/rest-api/tests/mocha/api-testing/AbuseFilterTest.js b/repo/rest-api/tests/mocha/api-testing/AbuseFilterTest.js new file mode 100644 index 0000000000..e895f4a322 --- /dev/null +++ b/repo/rest-api/tests/mocha/api-testing/AbuseFilterTest.js @@ -0,0 +1,94 @@ +'use strict'; + +const { clientFactory, action, utils } = require( 'api-testing' ); +const { + newCreateItemRequestBuilder, + newSetItemLabelRequestBuilder, + newSetPropertyLabelRequestBuilder, + newAddItemStatementRequestBuilder, + newAddPropertyStatementRequestBuilder +} = require( '../helpers/RequestBuilderFactory' ); +const { assertValidError } = require( '../helpers/responseValidator' ); +const { createUniqueStringProperty } = require( '../helpers/entityHelper' ); +const { requireExtensions } = require( '../../../../../tests/api-testing/utils' ); +const config = require( 'api-testing/lib/config' )(); + +/** + * AbuseFilter doesn't have an API to create filters. This is a very hacky way around the issue: + * - get the edit token (a CSRF token salted for the AbuseFilter form) + * - make a POST request that looks like it's coming from said form + * + * @param {string} description + * @param {string} rules + * @return {Promise} the filter ID + */ +async function createAbuseFilter( description, rules ) { + const rootClient = await action.root(); + const client = clientFactory.getHttpClient( rootClient ); + + const abuseFilterFormRequest = await client.get( `${config.base_uri}index.php?title=Special:AbuseFilter/new` ); + const editToken = abuseFilterFormRequest.text + .match( /value="[a-z0-9]+\+\\"/ )[ 0 ] // the token is in the value attribute of an input field and ends with +\ + .slice( 'value="'.length, -1 ); // remove parts that were matched that aren't part of the token + + const createFilterResponse = await client.post( `${config.base_uri}index.php` ).type( 'form' ).send( { + title: 'Special:AbuseFilter/new', + wpEditToken: editToken, + wpFilterDescription: description, + wpFilterRules: rules, + wpFilterEnabled: 'true', + wpFilterBuilder: 'other', + wpFilterNotes: '', + wpFilterWarnMessage: 'abusefilter-warning', + wpFilterWarnMessageOther: 'abusefilter-warning', + wpFilterActionDisallow: '', + wpFilterDisallowMessage: 'abusefilter-disallowed', + wpFilterDisallowMessageOther: 'abusefilter-disallowed', + wpBlockAnonDuration: 'indefinite', + wpBlockUserDuration: 'indefinite', + wpFilterTags: '' + } ); + + return parseInt( new URL( createFilterResponse.headers.location ).searchParams.get( 'changedfilter' ) ); +} + +describe( 'Abuse Filter', () => { + + const filterTriggerWord = utils.title( 'ABUSE-FILTER-TRIGGER-' ); + const filterDescription = `Filter: ${filterTriggerWord}`; + let filterId; + let testItemId; + let testPropertyId; + + before( async function () { + await requireExtensions( [ 'AbuseFilter' ] ).call( this ); + + filterId = await createAbuseFilter( filterDescription, `"${filterTriggerWord}" in new_wikitext` ); + testItemId = ( await newCreateItemRequestBuilder( {} ).makeRequest() ).body.id; + testPropertyId = ( await createUniqueStringProperty() ).entity.id; + } ); + + [ + () => newCreateItemRequestBuilder( { labels: { en: filterTriggerWord } } ), + () => newSetItemLabelRequestBuilder( testItemId, 'en', filterTriggerWord ), + () => newSetPropertyLabelRequestBuilder( testPropertyId, 'en', filterTriggerWord ), + () => newAddItemStatementRequestBuilder( + testItemId, + { property: { id: testPropertyId }, value: { type: 'value', content: filterTriggerWord } } + ), + () => newAddPropertyStatementRequestBuilder( + testPropertyId, + { property: { id: testPropertyId }, value: { type: 'value', content: filterTriggerWord } } + ) + ].forEach( ( newRequestBuilder ) => { + it( `${newRequestBuilder().getRouteDescription()} rejects edits matching an abuse filter`, async () => { + const response = await newRequestBuilder().makeRequest(); + + assertValidError( response, 403, 'permission-denied', { + denial_reason: 'abuse-filter', + denial_context: { filter_id: filterId, filter_description: filterDescription } + } ); + } ); + } ); + +} ); diff --git a/repo/rest-api/tests/phpunit/Infrastructure/DataAccess/EntityUpdaterTest.php b/repo/rest-api/tests/phpunit/Infrastructure/DataAccess/EntityUpdaterTest.php index acfd687db5..d978cff5b5 100644 --- a/repo/rest-api/tests/phpunit/Infrastructure/DataAccess/EntityUpdaterTest.php +++ b/repo/rest-api/tests/phpunit/Infrastructure/DataAccess/EntityUpdaterTest.php @@ -29,6 +29,7 @@ use Wikibase\Repo\EditEntity\MediaWikiEditEntityFactory; use Wikibase\Repo\RestApi\Domain\Model\EditMetadata; use Wikibase\Repo\RestApi\Domain\Model\EditSummary; +use Wikibase\Repo\RestApi\Domain\Services\Exceptions\AbuseFilterException; use Wikibase\Repo\RestApi\Domain\Services\Exceptions\ResourceTooLargeException; use Wikibase\Repo\RestApi\Infrastructure\DataAccess\EntityUpdater; use Wikibase\Repo\RestApi\Infrastructure\DataAccess\Exceptions\EntityUpdateFailed; @@ -225,6 +226,25 @@ public function testGivenResourceTooLarge_throwsCorrespondingException( EntityDo $this->newEntityUpdater()->update( $entity, $this->createStub( EditMetadata::class ) ); } + /** + * @dataProvider provideEntity + */ + public function testGivenAbuseFilterMatch_throwsCorrespondingException( EntityDocument $entity ): void { + $filterId = 777; + $filterDescription = 'bad word rejecting filter'; + + $errorStatus = EditEntityStatus::newFatal( 'abusefilter-disallowed', $filterDescription, $filterId ); + + $editEntity = $this->createStub( EditEntity::class ); + $editEntity->method( 'attemptSave' )->willReturn( $errorStatus ); + + $this->editEntityFactory = $this->createStub( MediaWikiEditEntityFactory::class ); + $this->editEntityFactory->method( 'newEditEntity' )->willReturn( $editEntity ); + + $this->expectExceptionObject( new AbuseFilterException( $filterId, $filterDescription ) ); + $this->newEntityUpdater()->update( $entity, $this->createStub( EditMetadata::class ) ); + } + /** * @dataProvider provideEntityAndErrorStatus */ @@ -266,7 +286,6 @@ public function provideEntityAndErrorStatus(): array { $errorStatuses = [ "basic 'actionthrottledtext' error" => [ EditEntityStatus::newFatal( 'actionthrottledtext' ) ], "wfMessage 'actionthrottledtext' error" => [ EditEntityStatus::newFatal( wfMessage( 'actionthrottledtext' ) ) ], - "'abusefilter-disallowed' error" => [ EditEntityStatus::newFatal( 'abusefilter-disallowed' ) ], "'spam-blacklisted-link' error" => [ EditEntityStatus::newFatal( 'spam-blacklisted-link' ) ], "'spam-blacklisted-email' error" => [ EditEntityStatus::newFatal( 'spam-blacklisted-email' ) ], ];