From f64ce7b46e1bd1695539a0abf424034b2e81c951 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 8 Nov 2023 15:28:05 +0800 Subject: [PATCH] MDL-81031 core: Add JS client-side validation --- lib/classes/param.php | 26 ++++++++++++++ lib/classes/param_clientside_regex.php | 40 +++++++++++++++++++++ lib/classes/router/schema/openapi_base.php | 4 +++ lib/tests/router/request_validator_test.php | 10 ++++++ lib/tests/router/schema/parameter_test.php | 1 + 5 files changed, 81 insertions(+) create mode 100644 lib/classes/param_clientside_regex.php diff --git a/lib/classes/param.php b/lib/classes/param.php index 9c817ff4f5b..91a78bb1b64 100644 --- a/lib/classes/param.php +++ b/lib/classes/param.php @@ -36,22 +36,26 @@ enum param: string { /** * PARAM_ALPHA - contains only English ascii letters [a-zA-Z]. */ + #[param_clientside_regex('^[a-zA-Z]+$')] case ALPHA = 'alpha'; /** * PARAM_ALPHAEXT the same contents as PARAM_ALPHA (English ascii letters [a-zA-Z]) plus the chars in quotes: "_-" allowed * NOTE: originally this allowed "/" too, please use PARAM_SAFEPATH if "/" needed */ + #[param_clientside_regex('^[a-zA-Z_\-]*$')] case ALPHAEXT = 'alphaext'; /** * PARAM_ALPHANUM - expected numbers 0-9 and English ascii letters [a-zA-Z] only. */ + #[param_clientside_regex('^[a-zA-Z0-9]*$')] case ALPHANUM = 'alphanum'; /** * PARAM_ALPHANUMEXT - expected numbers 0-9, letters (English ascii letters [a-zA-Z]) and _- only. */ + #[param_clientside_regex('^[a-zA-Z0-9_\-]*$')] case ALPHANUMEXT = 'alphanumext'; /** @@ -108,6 +112,7 @@ enum param: string { * This is preferred over PARAM_FLOAT for numbers typed in by the user. * Cleans localised numbers to computer readable numbers; false for invalid numbers. */ + #[param_clientside_regex('^\d*([\.,])\d+$')] case LOCALISEDFLOAT = 'localisedfloat'; /** @@ -165,6 +170,7 @@ enum param: string { /** * PARAM_SAFEDIR - safe directory name, suitable for include() and require() */ + #[param_clientside_regex('^[a-zA-Z0-9_\-]*$')] case SAFEDIR = 'safedir'; /** @@ -173,11 +179,13 @@ enum param: string { * * This is NOT intended to be used for absolute paths or any user uploaded files. */ + #[param_clientside_regex('^[a-zA-Z0-9\/_\-]*$')] case SAFEPATH = 'safepath'; /** * PARAM_SEQUENCE - expects a sequence of numbers like 8 to 1,5,6,4,6,8,9. Numbers and comma only. */ + #[param_clientside_regex('^[0-9,]*$')] case SEQUENCE = 'sequence'; /** @@ -316,6 +324,7 @@ enum param: string { * Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter. * NOTE: numbers and underscores are strongly discouraged in plugin names! */ + #[param_clientside_regex('^[a-z][a-z0-9]*(_(?:[a-z][a-z0-9_](?!__))*)?[a-z0-9]+$')] case COMPONENT = 'component'; /** @@ -323,6 +332,7 @@ enum param: string { * It is usually used together with context id and component. * Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter. */ + #[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')] case AREA = 'area'; /** @@ -330,6 +340,7 @@ enum param: string { * Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter. * NOTE: numbers and underscores are strongly discouraged in plugin names! Underscores are forbidden in module names. */ + #[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')] case PLUGIN = 'plugin'; /** @@ -409,6 +420,21 @@ enum param: string { return $this->{$methodname}($value); } + /** + * Get the clientside regular expression for this parameter. + * + * @return null|string + */ + public function get_clientside_expression(): ?string { + $ref = new \ReflectionClassConstant(self::class, $this->name); + $attributes = $ref->getAttributes(param_clientside_regex::class); + if (count($attributes) === 0) { + return null; + } + + return $attributes[0]->newInstance()->regex; + } + /** * Returns a value for the named variable, taken from request arguments. * diff --git a/lib/classes/param_clientside_regex.php b/lib/classes/param_clientside_regex.php new file mode 100644 index 00000000000..c2c6009e37d --- /dev/null +++ b/lib/classes/param_clientside_regex.php @@ -0,0 +1,40 @@ +. + +namespace core; + +use Attribute; + +/** + * A JS-compatible regular expression to validate the format of a param. + * + * @package core + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[Attribute(Attribute::TARGET_CLASS_CONSTANT)] +class param_clientside_regex { + /** + * Create a clientside regular expression for use with a \core\param enum case. + * + * @param string $regex The Regular Expression that validates the param case + */ + public function __construct( + /** @var string The Regular Expression that validates the param case */ + public readonly string $regex, + ) { + } +} diff --git a/lib/classes/router/schema/openapi_base.php b/lib/classes/router/schema/openapi_base.php index c464acfa7e5..a8a61fed2a9 100644 --- a/lib/classes/router/schema/openapi_base.php +++ b/lib/classes/router/schema/openapi_base.php @@ -234,6 +234,10 @@ abstract class openapi_base { default => 'string', }; + if ($pattern = $type->get_clientside_expression()) { + $data->pattern = $pattern; + } + return $data; } } diff --git a/lib/tests/router/request_validator_test.php b/lib/tests/router/request_validator_test.php index 49a8498b3d3..6ecdb1f88bf 100644 --- a/lib/tests/router/request_validator_test.php +++ b/lib/tests/router/request_validator_test.php @@ -122,7 +122,12 @@ final class request_validator_test extends route_testcase { * When a pathtype fails to validate, it will result in an HttpNotFoundException. */ public function test_validate_request_invalid_path_component(): void { + // Most of the path types are converted to regexes and will lead to a 404 before they get this far. $type = param::INT; + $this->assertEmpty( + $type->get_clientside_expression(), + 'This test requires a type with no clientside expression. Please update the test.', + ); $route = new route( path: '/example/{required}', @@ -145,7 +150,12 @@ final class request_validator_test extends route_testcase { * When a pathtype fails to validate, it will result in an HttpNotFoundException. */ public function test_validate_request_invalid_path_component_native(): void { + // Most of the path types are converted to regexes and will lead to a 404 before they get this far. $type = param::ALPHA; + $this->assertNotEmpty( + $type->get_clientside_expression(), + 'This test requires a type with clientside expression. Please update the test.', + ); $route = new route( path: '/example/{required}', diff --git a/lib/tests/router/schema/parameter_test.php b/lib/tests/router/schema/parameter_test.php index d488d91c7b8..a4a867e79ad 100644 --- a/lib/tests/router/schema/parameter_test.php +++ b/lib/tests/router/schema/parameter_test.php @@ -187,5 +187,6 @@ final class parameter_test extends route_testcase { $description = $param->get_openapi_description(new specification()); $this->assertNotNull($description->schema); $this->assertEquals('string', $description->schema->type); + $this->assertIsString($description->schema->pattern); } }