ParamValidator.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. <?php
  2. namespace Wikimedia\ParamValidator;
  3. use DomainException;
  4. use InvalidArgumentException;
  5. use Wikimedia\Assert\Assert;
  6. use Wikimedia\ObjectFactory;
  7. /**
  8. * Service for formatting and validating API parameters
  9. *
  10. * A settings array is simply an array with keys being the relevant PARAM_*
  11. * constants from this class, TypeDef, and its subclasses.
  12. *
  13. * As a general overview of the architecture here:
  14. * - ParamValidator handles some general validation of the parameter,
  15. * then hands off to a TypeDef subclass to validate the specific representation
  16. * based on the parameter's type.
  17. * - TypeDef subclasses handle conversion between the string representation
  18. * submitted by the client and the output PHP data types, validating that the
  19. * strings are valid representations of the intended type as they do so.
  20. * - ValidationException is used to report fatal errors in the validation back
  21. * to the caller, since the return value represents the successful result of
  22. * the validation and might be any type or class.
  23. * - The Callbacks interface allows ParamValidator to reach out and fetch data
  24. * it needs to perform the validation. Currently that includes:
  25. * - Fetching the value of the parameter being validated (largely since a generic
  26. * caller cannot know whether it needs to fetch a string from $_GET/$_POST or
  27. * an array from $_FILES).
  28. * - Reporting of non-fatal warnings back to the caller.
  29. * - Fetching the "high limits" flag when necessary, to avoid the need for loading
  30. * the user unnecessarily.
  31. *
  32. * @since 1.34
  33. * @unstable
  34. */
  35. class ParamValidator {
  36. /**
  37. * @name Constants for parameter settings arrays
  38. * These constants are keys in the settings array that define how the
  39. * parameters coming in from the request are to be interpreted.
  40. *
  41. * If a constant is associated with a ValidationException, the failure code
  42. * and data are described. ValidationExceptions are typically thrown, but
  43. * those indicated as "non-fatal" are instead passed to
  44. * Callbacks::recordCondition().
  45. *
  46. * Additional constants may be defined by TypeDef subclasses, or by other
  47. * libraries for controlling things like auto-generated parameter documentation.
  48. * For purposes of namespacing the constants, the values of all constants
  49. * defined by this library begin with 'param-'.
  50. *
  51. * @{
  52. */
  53. /** (mixed) Default value of the parameter. If omitted, null is the default. */
  54. const PARAM_DEFAULT = 'param-default';
  55. /**
  56. * (string|array) Type of the parameter.
  57. * Must be a registered type or an array of enumerated values (in which case the "enum"
  58. * type must be registered). If omitted, the default is the PHP type of the default value
  59. * (see PARAM_DEFAULT).
  60. */
  61. const PARAM_TYPE = 'param-type';
  62. /**
  63. * (bool) Indicate that the parameter is required.
  64. *
  65. * ValidationException codes:
  66. * - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
  67. */
  68. const PARAM_REQUIRED = 'param-required';
  69. /**
  70. * (bool) Indicate that the parameter is multi-valued.
  71. *
  72. * A multi-valued parameter may be submitted in one of several formats. All
  73. * of the following result a value of `[ 'a', 'b', 'c' ]`.
  74. * - "a|b|c", i.e. pipe-separated.
  75. * - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
  76. * - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
  77. *
  78. * Each of the multiple values is passed individually to the TypeDef.
  79. * $options will contain a 'values-list' key holding the entire list.
  80. *
  81. * By default duplicates are removed from the resulting parameter list. Use
  82. * PARAM_ALLOW_DUPLICATES to override that behavior.
  83. *
  84. * ValidationException codes:
  85. * - 'toomanyvalues': More values were supplied than are allowed. See
  86. * PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
  87. * 'ismultiLimits'. Data:
  88. * - 'limit': The limit that was exceeded.
  89. * - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
  90. * PARAM_IGNORE_INVALID_VALUES was set. Data:
  91. * - 'values': The unrecognized values.
  92. */
  93. const PARAM_ISMULTI = 'param-ismulti';
  94. /**
  95. * (int) Maximum number of multi-valued parameter values allowed
  96. *
  97. * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
  98. * the limit when useHighLimits() returns true.
  99. *
  100. * ValidationException codes:
  101. * - 'toomanyvalues': The limit was exceeded. Data:
  102. * - 'limit': The limit that was exceeded.
  103. */
  104. const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
  105. /**
  106. * (int) Maximum number of multi-valued parameter values allowed for users
  107. * allowed high limits.
  108. *
  109. * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
  110. * the limit when useHighLimits() returns true.
  111. *
  112. * ValidationException codes:
  113. * - 'toomanyvalues': The limit was exceeded. Data:
  114. * - 'limit': The limit that was exceeded.
  115. */
  116. const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
  117. /**
  118. * (bool|string) Whether a magic "all values" value exists for multi-valued
  119. * enumerated types, and if so what that value is.
  120. *
  121. * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
  122. * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
  123. * every possible value. If a string is set, it will be used in place of the asterisk.
  124. */
  125. const PARAM_ALL = 'param-all';
  126. /**
  127. * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
  128. *
  129. * If not truthy, the set of values will be passed through
  130. * `array_values( array_unique() )`. The default is falsey.
  131. */
  132. const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
  133. /**
  134. * (bool) Indicate that the parameter's value should not be logged.
  135. *
  136. * ValidationException codes: (non-fatal)
  137. * - 'param-sensitive': Always recorded.
  138. */
  139. const PARAM_SENSITIVE = 'param-sensitive';
  140. /**
  141. * (bool) Indicate that a deprecated parameter was used.
  142. *
  143. * ValidationException codes: (non-fatal)
  144. * - 'param-deprecated': Always recorded.
  145. */
  146. const PARAM_DEPRECATED = 'param-deprecated';
  147. /**
  148. * (bool) Whether to ignore invalid values.
  149. *
  150. * This controls whether certain ValidationExceptions are considered fatal
  151. * or non-fatal. The default is false.
  152. */
  153. const PARAM_IGNORE_INVALID_VALUES = 'param-ignore-invalid-values';
  154. /** @} */
  155. /** Magic "all values" value when PARAM_ALL is true. */
  156. const ALL_DEFAULT_STRING = '*';
  157. /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
  158. public static $STANDARD_TYPES = [
  159. 'boolean' => [ 'class' => TypeDef\BooleanDef::class ],
  160. 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ],
  161. 'integer' => [ 'class' => TypeDef\IntegerDef::class ],
  162. 'limit' => [ 'class' => TypeDef\LimitDef::class ],
  163. 'float' => [ 'class' => TypeDef\FloatDef::class ],
  164. 'double' => [ 'class' => TypeDef\FloatDef::class ],
  165. 'string' => [ 'class' => TypeDef\StringDef::class ],
  166. 'password' => [ 'class' => TypeDef\PasswordDef::class ],
  167. 'NULL' => [
  168. 'class' => TypeDef\StringDef::class,
  169. 'args' => [ [
  170. 'allowEmptyWhenRequired' => true,
  171. ] ],
  172. ],
  173. 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
  174. 'upload' => [ 'class' => TypeDef\UploadDef::class ],
  175. 'enum' => [ 'class' => TypeDef\EnumDef::class ],
  176. ];
  177. /** @var Callbacks */
  178. private $callbacks;
  179. /** @var ObjectFactory */
  180. private $objectFactory;
  181. /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
  182. private $typeDefs = [];
  183. /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
  184. private $ismultiLimit1;
  185. /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
  186. private $ismultiLimit2;
  187. /**
  188. * @param Callbacks $callbacks
  189. * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
  190. * @param array $options Associative array of additional settings
  191. * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
  192. * Pass an empty array if you want to start with no registered types.
  193. * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
  194. * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
  195. */
  196. public function __construct(
  197. Callbacks $callbacks,
  198. ObjectFactory $objectFactory,
  199. array $options = []
  200. ) {
  201. $this->callbacks = $callbacks;
  202. $this->objectFactory = $objectFactory;
  203. $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES );
  204. $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
  205. $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
  206. }
  207. /**
  208. * List known type names
  209. * @return string[]
  210. */
  211. public function knownTypes() {
  212. return array_keys( $this->typeDefs );
  213. }
  214. /**
  215. * Register multiple type handlers
  216. *
  217. * @see addTypeDef()
  218. * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
  219. */
  220. public function addTypeDefs( array $typeDefs ) {
  221. foreach ( $typeDefs as $name => $def ) {
  222. $this->addTypeDef( $name, $def );
  223. }
  224. }
  225. /**
  226. * Register a type handler
  227. *
  228. * To allow code to omit PARAM_TYPE in settings arrays to derive the type
  229. * from PARAM_DEFAULT, it is strongly recommended that the following types be
  230. * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
  231. *
  232. * When using ObjectFactory specs, the following extra arguments are passed:
  233. * - The Callbacks object for this ParamValidator instance.
  234. *
  235. * @param string $name Type name
  236. * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
  237. */
  238. public function addTypeDef( $name, $typeDef ) {
  239. Assert::parameterType(
  240. implode( '|', [ TypeDef::class, 'array' ] ),
  241. $typeDef,
  242. '$typeDef'
  243. );
  244. if ( isset( $this->typeDefs[$name] ) ) {
  245. throw new InvalidArgumentException( "Type '$name' is already registered" );
  246. }
  247. $this->typeDefs[$name] = $typeDef;
  248. }
  249. /**
  250. * Register a type handler, overriding any existing handler
  251. * @see addTypeDef
  252. * @param string $name Type name
  253. * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
  254. */
  255. public function overrideTypeDef( $name, $typeDef ) {
  256. Assert::parameterType(
  257. implode( '|', [ TypeDef::class, 'array', 'null' ] ),
  258. $typeDef,
  259. '$typeDef'
  260. );
  261. if ( $typeDef === null ) {
  262. unset( $this->typeDefs[$name] );
  263. } else {
  264. $this->typeDefs[$name] = $typeDef;
  265. }
  266. }
  267. /**
  268. * Test if a type is registered
  269. * @param string $name Type name
  270. * @return bool
  271. */
  272. public function hasTypeDef( $name ) {
  273. return isset( $this->typeDefs[$name] );
  274. }
  275. /**
  276. * Get the TypeDef for a type
  277. * @param string|array $type Any array is considered equivalent to the string "enum".
  278. * @return TypeDef|null
  279. */
  280. public function getTypeDef( $type ) {
  281. if ( is_array( $type ) ) {
  282. $type = 'enum';
  283. }
  284. if ( !isset( $this->typeDefs[$type] ) ) {
  285. return null;
  286. }
  287. $def = $this->typeDefs[$type];
  288. if ( !$def instanceof TypeDef ) {
  289. $def = $this->objectFactory->createObject( $def, [
  290. 'extraArgs' => [ $this->callbacks ],
  291. 'assertClass' => TypeDef::class,
  292. ] );
  293. $this->typeDefs[$type] = $def;
  294. }
  295. return $def;
  296. }
  297. /**
  298. * Normalize a parameter settings array
  299. * @param array|mixed $settings Default value or an array of settings
  300. * using PARAM_* constants.
  301. * @return array
  302. */
  303. public function normalizeSettings( $settings ) {
  304. // Shorthand
  305. if ( !is_array( $settings ) ) {
  306. $settings = [
  307. self::PARAM_DEFAULT => $settings,
  308. ];
  309. }
  310. // When type is not given, determine it from the type of the PARAM_DEFAULT
  311. if ( !isset( $settings[self::PARAM_TYPE] ) ) {
  312. $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null );
  313. }
  314. $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
  315. if ( $typeDef ) {
  316. $settings = $typeDef->normalizeSettings( $settings );
  317. }
  318. return $settings;
  319. }
  320. /**
  321. * Fetch and valiate a parameter value using a settings array
  322. *
  323. * @param string $name Parameter name
  324. * @param array|mixed $settings Default value or an array of settings
  325. * using PARAM_* constants.
  326. * @param array $options Options array, passed through to the TypeDef and Callbacks.
  327. * @return mixed Validated parameter value
  328. * @throws ValidationException if the value is invalid
  329. */
  330. public function getValue( $name, $settings, array $options = [] ) {
  331. $settings = $this->normalizeSettings( $settings );
  332. $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
  333. if ( !$typeDef ) {
  334. throw new DomainException(
  335. "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
  336. );
  337. }
  338. $value = $typeDef->getValue( $name, $settings, $options );
  339. if ( $value !== null ) {
  340. if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
  341. $this->callbacks->recordCondition(
  342. new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
  343. $options
  344. );
  345. }
  346. // Set a warning if a deprecated parameter has been passed
  347. if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
  348. $this->callbacks->recordCondition(
  349. new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
  350. $options
  351. );
  352. }
  353. } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
  354. $value = $settings[self::PARAM_DEFAULT];
  355. }
  356. return $this->validateValue( $name, $value, $settings, $options );
  357. }
  358. /**
  359. * Valiate a parameter value using a settings array
  360. *
  361. * @param string $name Parameter name
  362. * @param null|mixed $value Parameter value
  363. * @param array|mixed $settings Default value or an array of settings
  364. * using PARAM_* constants.
  365. * @param array $options Options array, passed through to the TypeDef and Callbacks.
  366. * - An additional option, 'values-list', will be set when processing the
  367. * values of a multi-valued parameter.
  368. * @return mixed Validated parameter value(s)
  369. * @throws ValidationException if the value is invalid
  370. */
  371. public function validateValue( $name, $value, $settings, array $options = [] ) {
  372. $settings = $this->normalizeSettings( $settings );
  373. $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
  374. if ( !$typeDef ) {
  375. throw new DomainException(
  376. "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
  377. );
  378. }
  379. if ( $value === null ) {
  380. if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
  381. throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
  382. }
  383. return null;
  384. }
  385. // Non-multi
  386. if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
  387. return $typeDef->validate( $name, $value, $settings, $options );
  388. }
  389. // Split the multi-value and validate each parameter
  390. $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
  391. $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2;
  392. $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 );
  393. // Handle PARAM_ALL
  394. $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
  395. if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
  396. count( $valuesList ) === 1
  397. ) {
  398. $allValue = is_string( $settings[self::PARAM_ALL] )
  399. ? $settings[self::PARAM_ALL]
  400. : self::ALL_DEFAULT_STRING;
  401. if ( $valuesList[0] === $allValue ) {
  402. return $enumValues;
  403. }
  404. }
  405. // Avoid checking useHighLimits() unless it's actually necessary
  406. $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options )
  407. ? $limit2
  408. : $limit1;
  409. if ( count( $valuesList ) > $sizeLimit ) {
  410. throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
  411. 'limit' => $sizeLimit
  412. ] );
  413. }
  414. $options['values-list'] = $valuesList;
  415. $validValues = [];
  416. $invalidValues = [];
  417. foreach ( $valuesList as $v ) {
  418. try {
  419. $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
  420. } catch ( ValidationException $ex ) {
  421. if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) {
  422. throw $ex;
  423. }
  424. $invalidValues[] = $v;
  425. }
  426. }
  427. if ( $invalidValues ) {
  428. $this->callbacks->recordCondition(
  429. new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
  430. 'values' => $invalidValues,
  431. ] ),
  432. $options
  433. );
  434. }
  435. // Throw out duplicates if requested
  436. if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
  437. $validValues = array_values( array_unique( $validValues ) );
  438. }
  439. return $validValues;
  440. }
  441. /**
  442. * Split a multi-valued parameter string, like explode()
  443. *
  444. * Note that, unlike explode(), this will return an empty array when given
  445. * an empty string.
  446. *
  447. * @param string $value
  448. * @param int $limit
  449. * @return string[]
  450. */
  451. public static function explodeMultiValue( $value, $limit ) {
  452. if ( $value === '' || $value === "\x1f" ) {
  453. return [];
  454. }
  455. if ( substr( $value, 0, 1 ) === "\x1f" ) {
  456. $sep = "\x1f";
  457. $value = substr( $value, 1 );
  458. } else {
  459. $sep = '|';
  460. }
  461. return explode( $sep, $value, $limit );
  462. }
  463. }