diff --git a/.github/workflows/php72-parse-smoke.yml b/.github/workflows/php72-parse-smoke.yml new file mode 100644 index 0000000..25fbdcd --- /dev/null +++ b/.github/workflows/php72-parse-smoke.yml @@ -0,0 +1,42 @@ +name: PHP 7.2 Parse and Smoke + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + php72-parse-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP 7.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '7.2' + coverage: none + tools: none + + - name: Parse source files + run: find src -type f -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Smoke SQL structure rules + run: | + php -r ' + require "src/Rule.php"; + require "src/Rules/SqlIdentifierRule.php"; + require "src/Rules/SortDirectionRule.php"; + + $identifier = new \BitApps\WPValidator\Rules\SqlIdentifierRule(); + $direction = new \BitApps\WPValidator\Rules\SortDirectionRule(); + + if (!$identifier->validate("sort_column") + || $identifier->validate("users.sort_column") + || !$direction->validate("DESC") + || $direction->validate("desc ") + || $direction->validate(0)) { + fwrite(STDERR, "PHP 7.2 SQL structure rule smoke check failed\n"); + exit(1); + } + ' diff --git a/README.md b/README.md index 33e1612..eaa59cc 100644 --- a/README.md +++ b/README.md @@ -167,14 +167,15 @@ Checks if the field under validation has a minimum value of `:min`. - For numeric data, the value corresponds to a given integer value. - For an array, the value corresponds to the count of the array. 17. **`nullable`**
-Makes the field under validation as optional (allows to be null), but respects other validation rules if specified and value is not null. +Skips later validation rules when the field is omitted, `null`, an empty string, or an empty array. A value of `false` continues through later validation rules. 18. **`numeric`**
Checks if the field under validation is a valid real number. 19. **`required`**
Checks if the field under validation is present and not empty. A field is "empty" if it meets one of the following criteria: - - The value is `NULL` or `FALSE`. + - The field is omitted or the value is `NULL`. - The value is an empty string. - - The value is an empty array or empty countable object. + - The value is an empty array. + - A value of `FALSE` is not empty and continues through validation. 20. **`present`**
The field must be present in the input data for validation. @@ -189,11 +190,36 @@ Checks if the field under validation has exactly the same size as `:size`. Checks if the given value is a string. 24. **`uppercase`**
Checks if the string value consists of all uppercase letters. -25. **`url`**
+25. **`sql_identifier`**
+Validates one unqualified identifier segment. It does not quote SQL. +26. **`sort_direction`**
+Validates `asc` or `desc`. It does not add an `ORDER BY` clause. +27. **`url`**
Checks if the value is a valid URL. Missing any validation rule that you need? Refer to the [Custom Validation Rule](#custom-validation-rule) section to know how you can create and use custom validation rules in your project alongside the library. +### SQL Structure Validation + +Use `sql_identifier` and `sort_direction` to validate request fields that select a +column and sort direction: + +```php +$rules = [ + 'sortBy' => ['required', 'string', 'sql_identifier'], + 'sortOrder' => ['required', 'string', 'sort_direction'], +]; +``` + +sql_identifier validates one unqualified identifier segment. It does not quote SQL. +sort_direction validates asc/desc. It does not add an ORDER BY clause. +sanitize:text and sanitize:key are not SQL-context security controls. +Raw SQL still requires typed identifier handling in the database layer. +nullable skips later rules for an omitted value, null, an empty string, or an empty +array. A false value continues through later validation; do not use nullable for +security-sensitive sort fields unless the request layer has already removed/defaulted +empty input. + ### Available sanitization functions 1. **`sanitize_email`**
Strip out all characters that are not allowable in an email address.
diff --git a/src/Helpers.php b/src/Helpers.php index 1859266..dfa636f 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -30,13 +30,21 @@ public function setNestedElement(&$data, $keys, $value) $current = &$data; foreach ($keys as $key) { + if (\is_null($current)) { + $current = []; + } + if (\is_array($current) && !isset($current[$key])) { $current[$key] = []; } elseif (\is_object($current) && !isset($current->{$key})) { $current->{$key} = new stdClass(); } - $current = &$current[$key] ?? $current->{$key}; + if (\is_array($current)) { + $current = &$current[$key]; + } else { + $current = &$current->{$key}; + } } $current = $value; diff --git a/src/Rules/SortDirectionRule.php b/src/Rules/SortDirectionRule.php new file mode 100644 index 0000000..a0fc06d --- /dev/null +++ b/src/Rules/SortDirectionRule.php @@ -0,0 +1,21 @@ +message; + } +} diff --git a/src/Rules/SqlIdentifierRule.php b/src/Rules/SqlIdentifierRule.php new file mode 100644 index 0000000..7a1d0d8 --- /dev/null +++ b/src/Rules/SqlIdentifierRule.php @@ -0,0 +1,21 @@ +message; + } +} diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php new file mode 100644 index 0000000..ceaabf9 --- /dev/null +++ b/tests/HelpersTest.php @@ -0,0 +1,50 @@ +setNestedElement($data, ['profile', 'name'], 'Ada'); + + expect($result)->toBe('Ada') + ->and($data)->toBe([ + 'profile' => [ + 'name' => 'Ada', + ], + ]); +}); + +test('promotes a null root to a nested array element', function () { + $helpers = new HelpersTestHarness(); + $data = null; + + $result = $helpers->setNestedElement($data, ['a', 'b'], 'x'); + + expect($result)->toBe('x') + ->and($data)->toBe([ + 'a' => [ + 'b' => 'x', + ], + ]); +}); + +test('sets a nested object property by reference', function () { + $helpers = new HelpersTestHarness(); + $data = new stdClass(); + + $result = $helpers->setNestedElement($data, ['profile', 'name'], 'Ada'); + + expect($result)->toBe('Ada') + ->and($data)->toEqual((object) [ + 'profile' => (object) [ + 'name' => 'Ada', + ], + ]); +}); diff --git a/tests/Rules/SortDirectionTest.php b/tests/Rules/SortDirectionTest.php new file mode 100644 index 0000000..0c90771 --- /dev/null +++ b/tests/Rules/SortDirectionTest.php @@ -0,0 +1,32 @@ +validate($value))->toBeTrue(); +})->with('valid sort directions'); + +test('rejects values that are not exact sort directions', function ($value) { + $rule = new SortDirectionRule(); + + expect($rule->validate($value))->toBeFalse(); +})->with('invalid sort directions'); diff --git a/tests/Rules/SqlIdentifierTest.php b/tests/Rules/SqlIdentifierTest.php new file mode 100644 index 0000000..1d5f949 --- /dev/null +++ b/tests/Rules/SqlIdentifierTest.php @@ -0,0 +1,49 @@ +validate($value))->toBeTrue(); +})->with('valid SQL identifiers'); + +test('rejects values that are not a single SQL identifier segment', function ($value) { + $rule = new SqlIdentifierRule(); + + expect($rule->validate($value))->toBeFalse(); +})->with('invalid SQL identifiers'); + +test('resolves sql_identifier and returns its validation message', function () { + $validator = new Validator(); + + $validation = $validator->make( + ['column' => 'users.id'], + ['column' => ['sql_identifier']] + ); + + expect($validation->errors())->toBe([ + 'column' => ['The column must be a valid SQL identifier'], + ]); +}); diff --git a/tests/SqlStructureValidationTest.php b/tests/SqlStructureValidationTest.php new file mode 100644 index 0000000..3c45fec --- /dev/null +++ b/tests/SqlStructureValidationTest.php @@ -0,0 +1,75 @@ + [[], [], false], + 'null' => [['value' => null], ['value' => null], false], + 'empty string' => [['value' => ''], ['value' => ''], false], + 'empty array' => [['value' => []], ['value' => []], false], + 'false' => [['value' => false], null, true], +]); + +test('validates safe SQL structure values without changing them', function () { + $validator = (new Validator())->make( + ['sortBy' => 'custom_field_12', 'sortOrder' => 'DESC'], + [ + 'sortBy' => ['required', 'sql_identifier'], + 'sortOrder' => ['required', 'sort_direction'], + ] + ); + + expect($validator->fails())->toBeFalse() + ->and($validator->validated())->toBe([ + 'sortBy' => 'custom_field_12', + 'sortOrder' => 'DESC', + ]); +}); + +test('returns the first labeled error for each malicious SQL structure value', function () { + $validator = (new Validator())->make( + [ + 'sortBy' => 'id`,(SELECT sleep(22) )#', + 'sortOrder' => 'desc; DROP', + ], + [ + 'sortBy' => ['required', 'sql_identifier', 'uppercase'], + 'sortOrder' => ['required', 'sort_direction', 'uppercase'], + ], + [], + [ + 'sortBy' => 'sort column', + 'sortOrder' => 'sort direction', + ] + ); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors())->toBe([ + 'sortBy' => ['The sort column must be a valid SQL identifier'], + 'sortOrder' => ['The sort direction must be asc or desc'], + ]) + ->and($validator->validated())->toBe([ + 'sortBy' => ['The sort column must be a valid SQL identifier'], + 'sortOrder' => ['The sort direction must be asc or desc'], + ]); +}); + +test('nullable skips SQL structure validation only for values considered empty', function ($rule, $data, $expectedValidated, $fails) { + $validator = (new Validator())->make( + $data, + ['value' => ['nullable', $rule]] + ); + + expect($validator->fails())->toBe($fails); + + if ($fails) { + expect($validator->errors())->toHaveKey('value'); + + return; + } + + expect($validator->validated())->toBe($expectedValidated); +})->with([ + 'sql identifier' => ['sql_identifier'], + 'sort direction' => ['sort_direction'], +])->with('nullable SQL structure values');