Skip to content

Commit

Permalink
REST: Respond 403 when AbuseFilter rejects edit
Browse files Browse the repository at this point in the history
Bug: T374959
Change-Id: I24b632b9b0172037d827167ccb1914769d947051
  • Loading branch information
jakobw committed Sep 20, 2024
1 parent 1b37a7c commit 587db15
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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(),
] );
}
}

Expand Down
1 change: 1 addition & 0 deletions repo/rest-api/src/Application/UseCases/UseCaseError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare( strict_types=1 );

namespace Wikibase\Repo\RestApi\Domain\Services\Exceptions;

use Exception;
use Throwable;

/**
* @license GPL-2.0-or-later
*/
class AbuseFilterException extends Exception {

private int $filterId;
private string $filterDescription;

public function __construct(
int $filterId,
string $filterDescription,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
parent::__construct( $message, $code, $previous );
$this->filterId = $filterId;
$this->filterDescription = $filterDescription;
}

public function getFilterId(): int {
return $this->filterId;
}

public function getFilterDescription(): string {
return $this->filterDescription;
}

}
11 changes: 9 additions & 2 deletions repo/rest-api/src/Infrastructure/DataAccess/EntityUpdater.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +71,7 @@ public function update( EntityDocument $entity, EditMetadata $editMetadata ): En
/**
* @throws EntityUpdateFailed
* @throws ResourceTooLargeException
* @throws AbuseFilterException
*/
private function createOrUpdate(
EntityDocument $entity,
Expand Down Expand Up @@ -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 );
}
Expand All @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions repo/rest-api/tests/mocha/api-testing/AbuseFilterTest.js
Original file line number Diff line number Diff line change
@@ -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<number>} 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 }
} );
} );
} );

} );
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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' ) ],
];
Expand Down

0 comments on commit 587db15

Please sign in to comment.