Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/php72-parse-smoke.yml
Original file line number Diff line number Diff line change
@@ -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);
}
'
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**<br/>
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`**<br/>
Checks if the field under validation is a valid real number.
19. **`required`**<br/>
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`**<br/>
The field must be present in the input data for validation.
Expand All @@ -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`**<br/>
Checks if the string value consists of all uppercase letters.
25. **`url`**<br/>
25. **`sql_identifier`**<br/>
Validates one unqualified identifier segment. It does not quote SQL.
26. **`sort_direction`**<br/>
Validates `asc` or `desc`. It does not add an `ORDER BY` clause.
27. **`url`**<br/>
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`**<br/>
Strip out all characters that are not allowable in an email address.<br/>
Expand Down
10 changes: 9 additions & 1 deletion src/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/Rules/SortDirectionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace BitApps\WPValidator\Rules;

use BitApps\WPValidator\Rule;

class SortDirectionRule extends Rule
{
private $message = 'The :attribute must be asc or desc';

public function validate($value)
{
return is_string($value)
&& in_array(strtolower($value), ['asc', 'desc'], true);
}

public function message()
{
return $this->message;
}
}
21 changes: 21 additions & 0 deletions src/Rules/SqlIdentifierRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace BitApps\WPValidator\Rules;

use BitApps\WPValidator\Rule;

class SqlIdentifierRule extends Rule
{
private $message = 'The :attribute must be a valid SQL identifier';

public function validate($value)
{
return is_string($value)
&& preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $value) === 1;
}

public function message()
{
return $this->message;
}
}
50 changes: 50 additions & 0 deletions tests/HelpersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

use BitApps\WPValidator\Helpers;

class HelpersTestHarness
{
use Helpers;
}

test('sets a nested array element by reference', function () {
$helpers = new HelpersTestHarness();
$data = [];

$result = $helpers->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',
],
]);
});
32 changes: 32 additions & 0 deletions tests/Rules/SortDirectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use BitApps\WPValidator\Rules\SortDirectionRule;

dataset('valid sort directions', [
'asc',
'ASC',
'desc',
'DESC',
]);

dataset('invalid sort directions', [
' asc',
'desc ',
[['asc']],
null,
'ascending',
'desc; DROP',
'desc nulls last',
]);

test('accepts asc and desc sort directions case-insensitively', function ($value) {
$rule = new SortDirectionRule();

expect($rule->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');
49 changes: 49 additions & 0 deletions tests/Rules/SqlIdentifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use BitApps\WPValidator\Rules\SqlIdentifierRule;
use BitApps\WPValidator\Validator;

dataset('valid SQL identifiers', [
'id',
'_private_column',
'custom_field_12',
'owner_ID',
]);

dataset('invalid SQL identifiers', [
'IF(1=1,SLEEP(5),id)',
'id`,(SELECT sleep(22) )#',
'id; DROP TABLE users',
'users.id',
'`id`',
'1column',
'id-name',
'',
null,
[['id']],
]);

test('accepts a single valid SQL identifier segment', function ($value) {
$rule = new SqlIdentifierRule();

expect($rule->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'],
]);
});
75 changes: 75 additions & 0 deletions tests/SqlStructureValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

use BitApps\WPValidator\Validator;

dataset('nullable SQL structure values', [
'omitted field' => [[], [], 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');
Loading