mediawiki-extensions-Templa.../includes/TemplateDataValidator.php
thiemowmde b632162245 Add missing validation for empty parameter names
Note this will make the templatedata API temporarily fail for
templates that are affected by this, until their documentation is
fixed. I think we have two ways to proceed here:

1. Thanks to Ie572809 we can change the API module to not do the
validation again. This allows us to make the validation more strict,
but the API module will continue to deliver the same data as before
until the parser cache is invalidated.

2. We accept it and merge this patch as it is. The problem is
extremely rare and easy to fix.

Bug: T333826
Change-Id: I16c7cc2328c47dde196e2dc07edb2eace33a624f
2023-04-11 19:08:37 +00:00

448 lines
12 KiB
PHP

<?php
namespace MediaWiki\Extension\TemplateData;
use Status;
use stdClass;
class TemplateDataValidator {
public const PREDEFINED_FORMATS = [
'block' => "{{_\n| _ = _\n}}",
'inline' => '{{_|_=_}}',
];
private const VALID_ROOT_KEYS = [
'description',
'params',
'paramOrder',
'sets',
'maps',
'format',
];
private const VALID_PARAM_KEYS = [
'label',
'required',
'suggested',
'description',
'example',
'deprecated',
'aliases',
'autovalue',
'default',
'inherits',
'type',
'suggestedvalues',
];
private const VALID_TYPES = [
'content',
'line',
'number',
'boolean',
'string',
'date',
'unbalanced-wikitext',
'unknown',
'url',
'wiki-page-name',
'wiki-user-name',
'wiki-file-name',
'wiki-template-name',
];
/** @var string[] */
private $validParameterTypes;
/**
* @param string[] $additionalParameterTypes
*/
public function __construct( array $additionalParameterTypes ) {
$this->validParameterTypes = array_merge( self::VALID_TYPES, $additionalParameterTypes );
}
/**
* @param mixed $data
*
* @return Status
*/
public function validate( $data ): Status {
if ( $data === null ) {
return Status::newFatal( 'templatedata-invalid-parse' );
}
if ( !( $data instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'templatedata', 'object' );
}
foreach ( $data as $key => $value ) {
if ( !in_array( $key, self::VALID_ROOT_KEYS ) ) {
return Status::newFatal( 'templatedata-invalid-unknown', $key );
}
}
// Root.description
if ( isset( $data->description ) ) {
if ( !$this->isValidInterfaceText( $data->description ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'description',
'string|object' );
}
}
// Root.format
if ( isset( $data->format ) ) {
if ( !is_string( $data->format ) ||
!( isset( self::PREDEFINED_FORMATS[$data->format] ) ||
$this->isValidCustomFormatString( $data->format )
)
) {
return Status::newFatal( 'templatedata-invalid-format', 'format' );
}
}
// Root.params
if ( !isset( $data->params ) ) {
return Status::newFatal( 'templatedata-invalid-missing', 'params', 'object' );
}
if ( !( $data->params instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'params', 'object' );
}
return $this->validateParameters( $data->params ) ??
$this->validateParameterOrder( $data->paramOrder ?? null, $data->params ) ??
$this->validateSets( $data->sets ?? [], $data->params ) ??
$this->validateMaps( $data->maps ?? (object)[], $data->params ) ??
Status::newGood();
}
/**
* @param stdClass $params
* @return Status|null Null on success, otherwise a Status object with the error message
*/
private function validateParameters( stdClass $params ): ?Status {
foreach ( $params as $paramName => $param ) {
if ( trim( $paramName ) === '' ) {
return Status::newFatal( 'templatedata-invalid-unnamed-parameter' );
}
if ( !( $param instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-type', "params.{$paramName}",
'object' );
}
$status = $this->validateParameter( $paramName, $param );
if ( $status ) {
return $status;
}
if ( isset( $param->inherits ) && !isset( $params->{ $param->inherits } ) ) {
return Status::newFatal( 'templatedata-invalid-missing',
"params.{$param->inherits}" );
}
}
return null;
}
/**
* @param string $paramName
* @param stdClass $param
* @return Status|null Null on success, otherwise a Status object with the error message
*/
private function validateParameter( string $paramName, stdClass $param ): ?Status {
foreach ( $param as $key => $value ) {
if ( !in_array( $key, self::VALID_PARAM_KEYS ) ) {
return Status::newFatal( 'templatedata-invalid-unknown',
"params.{$paramName}.{$key}" );
}
}
// Param.label
if ( isset( $param->label ) ) {
if ( !$this->isValidInterfaceText( $param->label ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.label", 'string|object' );
}
}
// Param.required
if ( isset( $param->required ) ) {
if ( !is_bool( $param->required ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.required", 'boolean' );
}
}
// Param.suggested
if ( isset( $param->suggested ) ) {
if ( !is_bool( $param->suggested ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.suggested", 'boolean' );
}
}
// Param.description
if ( isset( $param->description ) ) {
if ( !$this->isValidInterfaceText( $param->description ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.description", 'string|object' );
}
}
// Param.example
if ( isset( $param->example ) ) {
if ( !$this->isValidInterfaceText( $param->example ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.example", 'string|object' );
}
}
// Param.deprecated
if ( isset( $param->deprecated ) ) {
if ( !is_bool( $param->deprecated ) && !is_string( $param->deprecated ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.deprecated", 'boolean|string' );
}
}
// Param.aliases
if ( isset( $param->aliases ) ) {
if ( !is_array( $param->aliases ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.aliases", 'array' );
}
foreach ( $param->aliases as $i => $alias ) {
if ( !is_int( $alias ) && !is_string( $alias ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.aliases[$i]", 'int|string' );
}
}
}
// Param.autovalue
if ( isset( $param->autovalue ) ) {
if ( !is_string( $param->autovalue ) ) {
// TODO: Validate the autovalue values.
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.autovalue", 'string' );
}
}
// Param.default
if ( isset( $param->default ) ) {
if ( !$this->isValidInterfaceText( $param->default ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.default", 'string|object' );
}
}
// Param.type
if ( isset( $param->type ) ) {
if ( !is_string( $param->type ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.type", 'string' );
}
if ( !in_array( $param->type, $this->validParameterTypes ) ) {
return Status::newFatal( 'templatedata-invalid-value',
'params.' . $paramName . '.type' );
}
}
// Param.suggestedvalues
if ( isset( $param->suggestedvalues ) ) {
if ( !is_array( $param->suggestedvalues ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.suggestedvalues", 'array' );
}
foreach ( $param->suggestedvalues as $i => $value ) {
if ( !is_string( $value ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"params.{$paramName}.suggestedvalues[$i]", 'string' );
}
}
}
return null;
}
/**
* @param mixed $paramOrder
* @param stdClass $params
*
* @return Status|null
*/
private function validateParameterOrder( $paramOrder, stdClass $params ): ?Status {
if ( $paramOrder === null ) {
return null;
} elseif ( !is_array( $paramOrder ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'paramOrder', 'array' );
} elseif ( count( $paramOrder ) < count( (array)$params ) ) {
$firstMissing = count( $paramOrder );
return Status::newFatal( 'templatedata-invalid-missing', "paramOrder[$firstMissing]" );
}
// Validate each of the values corresponds to a parameter and that there are no
// duplicates
$seen = [];
foreach ( $paramOrder as $i => $param ) {
if ( !isset( $params->$param ) ) {
return Status::newFatal( 'templatedata-invalid-value', "paramOrder[$i]" );
}
if ( isset( $seen[$param] ) ) {
return Status::newFatal( 'templatedata-invalid-duplicate-value',
"paramOrder[$i]", "paramOrder[{$seen[$param]}]", $param );
}
$seen[$param] = $i;
}
return null;
}
/**
* @param mixed $sets
* @param stdClass $params
*
* @return Status|null
*/
private function validateSets( $sets, stdClass $params ): ?Status {
if ( !is_array( $sets ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'sets', 'array' );
}
foreach ( $sets as $setNr => $setObj ) {
if ( !( $setObj instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-value', "sets.{$setNr}" );
}
if ( !isset( $setObj->label ) ) {
return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.label",
'string|object' );
}
if ( !$this->isValidInterfaceText( $setObj->label ) ) {
return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.label",
'string|object' );
}
if ( !isset( $setObj->params ) ) {
return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.params",
'array' );
}
if ( !is_array( $setObj->params ) ) {
return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.params",
'array' );
}
if ( !count( $setObj->params ) ) {
return Status::newFatal( 'templatedata-invalid-empty-array',
"sets.{$setNr}.params" );
}
foreach ( $setObj->params as $i => $param ) {
if ( !isset( $params->$param ) ) {
return Status::newFatal( 'templatedata-invalid-value',
"sets.{$setNr}.params[$i]" );
}
}
}
return null;
}
/**
* @param mixed $maps
* @param stdClass $params
*
* @return Status|null
*/
private function validateMaps( $maps, stdClass $params ): ?Status {
if ( !( $maps instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-type', 'maps', 'object' );
}
foreach ( $maps as $consumerId => $map ) {
if ( !( $map instanceof stdClass ) ) {
return Status::newFatal( 'templatedata-invalid-type', "maps.$consumerId",
'object' );
}
foreach ( $map as $key => $value ) {
// Key is not validated as this is used by a third-party application
// Value must be 2d array of parameter names, 1d array of parameter names, or valid
// parameter name
if ( is_array( $value ) ) {
foreach ( $value as $key2 => $value2 ) {
if ( is_array( $value2 ) ) {
foreach ( $value2 as $key3 => $value3 ) {
if ( !is_string( $value3 ) ) {
return Status::newFatal( 'templatedata-invalid-type',
"maps.{$consumerId}.{$key}[$key2][$key3]", 'string' );
}
if ( !isset( $params->$value3 ) ) {
return Status::newFatal( 'templatedata-invalid-param', $value3,
"maps.$consumerId.{$key}[$key2][$key3]" );
}
}
} elseif ( is_string( $value2 ) ) {
if ( !isset( $params->$value2 ) ) {
return Status::newFatal( 'templatedata-invalid-param', $value2,
"maps.$consumerId.{$key}[$key2]" );
}
} else {
return Status::newFatal( 'templatedata-invalid-type',
"maps.{$consumerId}.{$key}[$key2]", 'string|array' );
}
}
} elseif ( is_string( $value ) ) {
if ( !isset( $params->$value ) ) {
return Status::newFatal( 'templatedata-invalid-param', $value,
"maps.{$consumerId}.{$key}" );
}
} else {
return Status::newFatal( 'templatedata-invalid-type',
"maps.{$consumerId}.{$key}", 'string|array' );
}
}
}
return null;
}
/**
* @param string|null $format
* @return bool
*/
private function isValidCustomFormatString( ?string $format ): bool {
return $format && preg_match( '/^\n?{{ *_+\n? *\|\n? *_+ *= *_+\n? *}}\n?$/', $format );
}
/**
* @param mixed $text
* @return bool
*/
private function isValidInterfaceText( $text ): bool {
if ( $text instanceof stdClass ) {
$isEmpty = true;
// An (array) cast would return private/protected properties as well
foreach ( get_object_vars( $text ) as $languageCode => $string ) {
// TODO: Do we need to validate if these are known interface language codes?
if ( !is_string( $languageCode ) ||
ltrim( $languageCode ) === '' ||
!is_string( $string )
) {
return false;
}
$isEmpty = false;
}
return !$isEmpty;
}
return is_string( $text );
}
}