diff --git a/i18n/en.json b/i18n/en.json
index b9cb8c3ff..c8ce444c4 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -536,6 +536,7 @@
"abusefilter-diff-next": "Newer change",
"abusefilter-import-intro": "You can use this interface to import filters from other wikis.\nOn the source wiki, click \"{{int:abusefilter-edit-export}}\" under \"{{int:abusefilter-edit-tools}}\" on the editing interface.\nCopy from the textbox that appears, and paste it into this textbox, then click \"{{int:abusefilter-import-submit}}\".",
"abusefilter-import-submit": "Import data",
+ "abusefilter-import-invalid-data": "The data you tried to import is not valid",
"abusefilter-group-default": "Default",
"abusefilter-http-error": "An HTTP error occurred: $1.",
"abusefilter-view-privatedetails-submit": "View private details",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index bc094383c..09b26e29f 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -570,6 +570,7 @@
"abusefilter-diff-next": "Link to the diff view for the next change to this filter.\n\nSee also:\n* {{msg-mw|Abusefilter-diff-prev}}\n* {{msg-mw|Previousdiff}} and {{msg-mw|Nextdiff}}",
"abusefilter-import-intro": "{{doc-important|Do not translate {{int:abusefilter-edit-export}}
, {{int:abusefilter-tools-subtitle}}
, and {{int:abusefilter-import-submit}}
unless you absolute must substitute any of them.}}\n\nRefers to:\n* {{msg-mw|Abusefilter-edit-export}}\n* {{msg-mw|Abusefilter-edit-tools}}\n* {{msg-mw|Abusefilter-import-submit}}",
"abusefilter-import-submit": "Used as label for the Submit button.\n\nPreceded by the textarea.\n\nUsed in:\n* {{msg-mw|Abusefilter-import-intro}}.",
+ "abusefilter-import-invalid-data": "Error message shown when provided data is invalid.",
"abusefilter-group-default": "The name for the default filter group. Most filters will be in this group.\n{{Identical|Default}}",
"abusefilter-http-error": "Error message for HTTP requests. Parameters:\n* $1 - HTTP response code.",
"abusefilter-view-privatedetails-submit": "Submit button label for viewing private details of an abuse log",
diff --git a/includes/AbuseFilter.php b/includes/AbuseFilter.php
index 662f1f625..a9964620d 100644
--- a/includes/AbuseFilter.php
+++ b/includes/AbuseFilter.php
@@ -641,6 +641,23 @@ class AbuseFilter {
return self::$filterCache[$filter];
}
+ /**
+ * Checks whether the given object represents a full abuse_filter DB row
+ * @param stdClass $row
+ * @return bool
+ */
+ public static function isFullAbuseFilterRow( stdClass $row ) {
+ $actual = array_keys( get_object_vars( $row ) );
+
+ if (
+ count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS )
+ || array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual )
+ ) {
+ return false;
+ }
+ return true;
+ }
+
/**
* Saves an abuse_filter row in cache
* @param string $id Filter ID (integer or "")
@@ -650,11 +667,7 @@ class AbuseFilter {
public static function cacheFilter( $id, $row ) {
// Check that all fields have been passed, otherwise using self::getFilter for this
// row will return partial data.
- $actual = array_keys( get_object_vars( $row ) );
-
- if ( count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS )
- || array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual )
- ) {
+ if ( !self::isFullAbuseFilterRow( $row ) ) {
throw new UnexpectedValueException( 'The specified row must be a full abuse_filter row.' );
}
self::$filterCache[$id] = $row;
diff --git a/includes/Views/AbuseFilterViewEdit.php b/includes/Views/AbuseFilterViewEdit.php
index 958f16c26..a03d7f3ca 100644
--- a/includes/Views/AbuseFilterViewEdit.php
+++ b/includes/Views/AbuseFilterViewEdit.php
@@ -87,7 +87,18 @@ class AbuseFilterViewEdit extends AbuseFilterView {
if ( $isImport || ( $request->wasPosted() && !$tokenMatches ) ) {
// Make sure to load from HTTP if the token doesn't match!
- $data = $this->loadRequest( $filter );
+ $status = $this->loadRequest( $filter );
+ if ( !$status->isGood() ) {
+ $out->addHTML(
+ Xml::tags(
+ 'p',
+ null,
+ Html::errorBox( $status->getMessage()->parse() )
+ )
+ );
+ return;
+ }
+ $data = $status->getValue();
} else {
$data = $this->loadFromDatabase( $filter, $history_id );
}
@@ -106,7 +117,12 @@ class AbuseFilterViewEdit extends AbuseFilterView {
*/
private function saveCurrentFilter( $filter, $history_id ) : void {
$out = $this->getOutput();
- list( $newRow, $actions ) = $this->loadRequest( $filter );
+ $reqStatus = $this->loadRequest( $filter );
+ if ( !$reqStatus->isGood() ) {
+ // In the current implementation, this cannot happen.
+ throw new LogicException( 'Should always be able to retrieve data for saving' );
+ }
+ list( $newRow, $actions ) = $reqStatus->getValue();
$dbw = wfGetDB( DB_MASTER );
$status = AbuseFilter::saveFilter( $this, $filter, $newRow, $actions, $dbw );
@@ -1168,9 +1184,10 @@ class AbuseFilterViewEdit extends AbuseFilterView {
* @throws BadMethodCallException If called without the request being POSTed or when trying
* to import a filter but $filter is not 'new'
* @param int|string $filter The filter ID being requested.
- * @return array
+ * @return Status If good, the value is the array [ row, actions ]. If not, it contains an
+ * error message.
*/
- public function loadRequest( $filter ): array {
+ public function loadRequest( $filter ): Status {
$request = $this->getRequest();
if ( !$request->wasPosted() ) {
// Sanity
@@ -1197,6 +1214,10 @@ class AbuseFilterViewEdit extends AbuseFilterView {
}
$data = FormatJson::decode( $import );
+ if ( !$this->isValidImportData( $data ) ) {
+ return Status::newFatal( 'abusefilter-import-invalid-data' );
+ }
+
$importRow = $data->row;
$actions = wfObjectToArray( $data->actions );
@@ -1309,7 +1330,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$row->af_actions = implode( ',', array_keys( $actions ) );
- return [ $row, $actions ];
+ return Status::newGood( [ $row, $actions ] );
}
/**
@@ -1348,4 +1369,42 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' )
);
}
+
+ /**
+ * Perform basic validation on the JSON-decoded import data. This doesn't check if parameters
+ * are valid etc., but only if the shape of the object is right.
+ *
+ * @param mixed $data Already JSON-decoded
+ * @return bool
+ */
+ private function isValidImportData( $data ) {
+ global $wgAbuseFilterActions;
+
+ if ( !is_object( $data ) ) {
+ return false;
+ }
+
+ $arr = get_object_vars( $data );
+
+ $expectedKeys = [ 'row' => true, 'actions' => true ];
+ if ( count( $arr ) !== count( $expectedKeys ) || array_diff_key( $arr, $expectedKeys ) ) {
+ return false;
+ }
+
+ if ( !is_object( $arr['row'] ) || !is_object( $arr['actions'] ) ) {
+ return false;
+ }
+
+ foreach ( $arr['actions'] as $action => $params ) {
+ if ( !array_key_exists( $action, $wgAbuseFilterActions ) || !is_array( $params ) ) {
+ return false;
+ }
+ }
+
+ if ( !AbuseFilter::isFullAbuseFilterRow( $arr['row'] ) ) {
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/includes/Views/AbuseFilterViewImport.php b/includes/Views/AbuseFilterViewImport.php
index a6d147d39..80dc8c98e 100644
--- a/includes/Views/AbuseFilterViewImport.php
+++ b/includes/Views/AbuseFilterViewImport.php
@@ -17,6 +17,7 @@ class AbuseFilterViewImport extends AbuseFilterView {
$formDescriptor = [
'ImportText' => [
'type' => 'textarea',
+ 'required' => true
]
];
HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
diff --git a/tests/phpunit/AbuseFilterSaveTest.php b/tests/phpunit/AbuseFilterSaveTest.php
index 31d7b1241..73919b537 100644
--- a/tests/phpunit/AbuseFilterSaveTest.php
+++ b/tests/phpunit/AbuseFilterSaveTest.php
@@ -209,7 +209,11 @@ class AbuseFilterSaveTest extends MediaWikiTestCase {
$existing = isset( $args['testData']['existing'] );
$viewEdit = $this->getViewEdit( $user, $params, $existing );
- list( $newRow, $actions ) = $viewEdit->loadRequest( $filter );
+ $reqStatus = $viewEdit->loadRequest( $filter );
+ if ( !$reqStatus->isGood() ) {
+ $this->fail( 'Cannot retrieve request data correctly' );
+ }
+ list( $newRow, $actions ) = $reqStatus->getValue();
/** @var IDatabase|MockObject $dbw */
$dbw = $this->getMockForAbstractClass( IDatabase::class );