├── .gitignore ├── src ├── InvalidDataException.php ├── Contract │ ├── IndexPolicyInterface.php │ ├── FilterContainerInterface.php │ └── FilterInterface.php ├── GeneralFilterContainer.php ├── IndexPolicy │ ├── IntegersOnly.php │ ├── StringsOnly.php │ ├── AnyIndex.php │ └── IndexAllowList.php ├── Filter │ ├── Special │ │ ├── CreditCardNumberFilter.php │ │ ├── DateTimeFilter.php │ │ └── EmailAddressFilter.php │ ├── BoolFilter.php │ ├── ArrayFilter.php │ ├── BoolArrayFilter.php │ ├── StringArrayFilter.php │ ├── FloatFilter.php │ ├── IntArrayFilter.php │ ├── IntFilter.php │ ├── FloatArrayFilter.php │ ├── StrictArrayFilter.php │ ├── StringFilter.php │ └── AllowList.php ├── Util.php ├── InputFilter.php └── InputFilterContainer.php ├── tests ├── Errata │ └── GenericFilterContainer.php ├── AllowListTest.php ├── IndexPolicyTest.php ├── FilterTest.php ├── SpecialTest.php └── ArrayFilterTest.php ├── .github └── workflows │ ├── psalm.yml │ └── ci.yml ├── LICENSE ├── phpunit.xml.dist ├── composer.json ├── psalm.xml ├── README.md └── docs ├── nosql-injection-prevention.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .phpunit.result.cache 3 | /composer.phar 4 | /composer.lock 5 | /vendor 6 | -------------------------------------------------------------------------------- /src/InvalidDataException.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function getFiltersForPath(string $path); 23 | } 24 | -------------------------------------------------------------------------------- /src/IndexPolicy/IndexAllowList.php: -------------------------------------------------------------------------------- 1 | allowed = $allowed; 18 | } 19 | 20 | /** 21 | * Any integer or string key is valid. 22 | * 23 | * @param array-key $index 24 | * @return bool 25 | */ 26 | public function indexIsValid($index): bool 27 | { 28 | return in_array($index, $this->allowed, true); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/psalm.yml: -------------------------------------------------------------------------------- 1 | name: Psalm 2 | 3 | on: [push] 4 | 5 | jobs: 6 | psalm: 7 | name: Psalm on PHP ${{ matrix.php-versions }} 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-latest'] 12 | php-versions: ['8.4'] 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php-versions }} 21 | tools: psalm:6 22 | coverage: none 23 | 24 | - name: Install Composer dependencies 25 | uses: "ramsey/composer-install@v3" 26 | with: 27 | composer-options: --no-dev 28 | 29 | - name: Static Analysis 30 | run: psalm 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * ISC License 3 | * 4 | * Copyright (c) 2016-2025 5 | * Paragon Initiative Enterprises 6 | * 7 | * Permission to use, copy, modify, and/or distribute this software for any 8 | * purpose with or without fee is hereby granted, provided that the above 9 | * copyright notice and this permission notice appear in all copies. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | */ -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | modern: 7 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-latest'] 12 | php-versions: ['8.1', '8.2', '8.3', '8.4', '8.5'] 13 | phpunit-versions: ['latest'] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php-versions }} 22 | extensions: mbstring, intl, sodium 23 | ini-values: post_max_size=256M, max_execution_time=180 24 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 25 | 26 | - name: Install dependencies 27 | run: composer install 28 | - name: PHPUnit tests 29 | run: vendor/bin/phpunit 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests 17 | 18 | 19 | 20 | 21 | 22 | src 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/ionizer", 3 | "description": "Powerful input value filtering for PHP applications", 4 | "authors": [ 5 | { 6 | "name": "Paragon Initiative Enterprises", 7 | "email": "info@paragonie.com", 8 | "homepage": "https://paragonie.com", 9 | "role": "Development Team" 10 | } 11 | ], 12 | "config": { 13 | "preferred-install": "dist", 14 | "optimize-autoloader": true 15 | }, 16 | "autoload": { 17 | "psr-4": { 18 | "ParagonIE\\Ionizer\\": "./src" 19 | } 20 | }, 21 | "autoload-dev": { 22 | "psr-4": { 23 | "ParagonIE\\Ionizer\\Test\\": "./tests" 24 | } 25 | }, 26 | "license": "ISC", 27 | "require": { 28 | "ext-json": "*", 29 | "php": "^8.1", 30 | "paragonie/constant_time_encoding": "^2.1|^3" 31 | }, 32 | "require-dev": { 33 | "phpunit/phpunit": "^10|^11" 34 | }, 35 | "scripts": { 36 | "test": ["phpunit", "psalm"] 37 | }, 38 | "support": { 39 | "email": "security@paragonie.com", 40 | "issues": "https://github.com/paragonie/ionizer/issues", 41 | "source": "https://github.com/paragonie/ionizer" 42 | } 43 | } -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Contract/FilterInterface.php: -------------------------------------------------------------------------------- 1 | addFilter( 26 | 'test1', 27 | (new AllowList( 28 | 'abc', 29 | 'def', 30 | 'ghi' 31 | ))->setDefault('jkl') 32 | ); 33 | 34 | if (!($filter instanceof GeneralFilterContainer)) { 35 | $this->fail('Type error'); 36 | } 37 | 38 | $before = [ 39 | 'test1' => 'abc' 40 | ]; 41 | $after = $filter($before); 42 | 43 | $this->assertSame( 44 | [ 45 | 'test1' => 'abc' 46 | ], 47 | $after 48 | ); 49 | 50 | $before = [ 51 | 'test1' => 0.123 52 | ]; 53 | $after = $filter($before); 54 | 55 | $this->assertSame( 56 | [ 57 | 'test1' => 'jkl' 58 | ], 59 | $after 60 | ); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Filter/Special/CreditCardNumberFilter.php: -------------------------------------------------------------------------------- 1 | addCallback([__CLASS__, 'validateCreditCardNumber']); 18 | } 19 | 20 | /** 21 | * Validate a credit card number, based on input length and Luhn's Algorithm 22 | * 23 | * @param string $input 24 | * 25 | * @return string 26 | * @throws InvalidDataException 27 | */ 28 | public static function validateCreditCardNumber(string $input): string 29 | { 30 | // Strip all non-decimal characters 31 | $stripped = \preg_replace('/[^0-9]/', '', $input); 32 | $length = Binary::safeStrlen($stripped); 33 | if ($length < 13 || $length > 19) { 34 | throw new InvalidDataException('Invalid credit card number (invalid length)'); 35 | } 36 | /** @var array $split */ 37 | $split = \str_split($stripped, 1); 38 | 39 | $calc = 0; 40 | $l = \count($split); 41 | for ($i = 0; $i < $l; ++$i) { 42 | $n = (int) ($split[$l - $i - 1]) << ($i & 1); 43 | if ($n > 9) { 44 | $n = ((int) ($n / 10)) + ($n % 10); 45 | } 46 | $calc += $n; 47 | } 48 | 49 | if ($calc % 10 !== 0) { 50 | throw new InvalidDataException('Invalid credit card number (Luhn)'); 51 | } 52 | return $stripped; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Util.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public static function chunk(string $str, string $token = '/'): array 19 | { 20 | return \explode( 21 | $token, 22 | \trim($str, $token) 23 | ); 24 | } 25 | 26 | /** 27 | * @param mixed $input 28 | * @return string 29 | * @throws \TypeError 30 | */ 31 | public static function getType($input): string 32 | { 33 | if (\is_null($input)) { 34 | return 'null'; 35 | } 36 | if (\is_callable($input)) { 37 | return 'callable'; 38 | } 39 | if (\is_resource($input)) { 40 | return 'resource'; 41 | } 42 | if (\is_object($input)) { 43 | return \get_class($input); 44 | } 45 | if (\is_string($input)) { 46 | return 'string'; 47 | } 48 | $type = \gettype($input); 49 | switch ($type) { 50 | case 'boolean': 51 | return 'bool'; 52 | case 'double': 53 | return 'float'; 54 | case 'integer': 55 | return 'int'; 56 | } 57 | throw new \TypeError('Unknown type'); 58 | } 59 | 60 | /** 61 | * Returns true if every member of an array is NOT another array 62 | * 63 | * @param array $source 64 | * @return bool 65 | */ 66 | public static function is1DArray(array $source): bool 67 | { 68 | return \count($source) === \count($source, \COUNT_RECURSIVE); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Filter/Special/DateTimeFilter.php: -------------------------------------------------------------------------------- 1 | dateTimeFormat = $format; 35 | $this->tz = $tz; 36 | } 37 | 38 | /** 39 | * Apply all of the callbacks for this filter. 40 | * 41 | * @throws TypeError 42 | * @throws InvalidDataException 43 | */ 44 | #[ReturnTypeWillChange] 45 | public function applyCallbacks(mixed $data = null, int $offset = 0): string 46 | { 47 | if ($offset === 0) { 48 | if (!\is_null($data)) { 49 | $data = (string) $data; 50 | try { 51 | /** @var string $data */ 52 | $data = (new DateTime($data, $this->tz)) 53 | ->format($this->dateTimeFormat); 54 | } catch (\Exception $ex) { 55 | throw new InvalidDataException( 56 | 'Invalid date/time', 57 | 0, 58 | $ex 59 | ); 60 | } 61 | } 62 | } 63 | return parent::applyCallbacks($data, $offset); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Filter/BoolFilter.php: -------------------------------------------------------------------------------- 1 | index) 43 | ); 44 | } 45 | return (bool) parent::process(!empty($data)); 46 | } 47 | 48 | /** 49 | * Sets the expected input type (e.g. string, boolean) 50 | * 51 | * @param string $typeIndicator 52 | * @return FilterInterface 53 | * @throws TypeError 54 | */ 55 | public function setType(string $typeIndicator): FilterInterface 56 | { 57 | if ($typeIndicator !== 'bool') { 58 | throw new TypeError( 59 | 'Type must always be set to "bool".' 60 | ); 61 | } 62 | return parent::setType('bool'); 63 | } 64 | 65 | /** 66 | * Set the default value (not applicable to booleans) 67 | * 68 | * @param string|int|float|bool|array|null $value 69 | * @return FilterInterface 70 | */ 71 | public function setDefault($value): FilterInterface 72 | { 73 | return parent::setDefault($value); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Filter/ArrayFilter.php: -------------------------------------------------------------------------------- 1 | indexPolicy = $indexPolicy; 35 | return $this; 36 | } 37 | 38 | /** 39 | * Process data using the filter rules. 40 | * 41 | * @param mixed $data 42 | * @return array 43 | * @throws TypeError 44 | * @throws InvalidDataException 45 | */ 46 | #[ReturnTypeWillChange] 47 | public function process(mixed $data = null): array 48 | { 49 | if (is_array($data)) { 50 | $data = (array) $data; 51 | } elseif (is_null($data)) { 52 | $data = []; 53 | } else { 54 | throw new TypeError( 55 | sprintf('Expected an array (%s).', $this->index) 56 | ); 57 | } 58 | if (!is_null($this->indexPolicy)) { 59 | $keys = array_keys($data); 60 | foreach ($keys as $arrayKey) { 61 | if (!$this->indexPolicy->indexIsValid($arrayKey)) { 62 | throw new TypeError( 63 | sprintf("Invalid key (%s) in violation of key policy", $arrayKey) 64 | ); 65 | } 66 | } 67 | } 68 | return (array) parent::process($data); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Filter/BoolArrayFilter.php: -------------------------------------------------------------------------------- 1 | index) 42 | ); 43 | } 44 | $data = (array) $data; 45 | 46 | if (!Util::is1DArray($data)) { 47 | throw new TypeError( 48 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 49 | ); 50 | } 51 | /** 52 | * @var array $data 53 | * @var int|string $key 54 | * @var string|int|float|bool|array|null $val 55 | */ 56 | foreach ($data as $key => $val) { 57 | if (is_array($val)) { 58 | throw new TypeError( 59 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 60 | ); 61 | } 62 | $data[$key] = !empty($val); 63 | } 64 | return parent::applyCallbacks($data, 0); 65 | } 66 | return parent::applyCallbacks($data, $offset); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Filter/StringArrayFilter.php: -------------------------------------------------------------------------------- 1 | index) 37 | ); 38 | } 39 | /** @var array $data */ 40 | $data = (array) $data; 41 | if (!Util::is1DArray((array) $data)) { 42 | throw new \TypeError( 43 | \sprintf('Expected a 1-dimensional array (%s).', $this->index) 44 | ); 45 | } 46 | /** @var string|int|float|bool|array|null $val */ 47 | foreach ($data as $key => $val) { 48 | if (\is_array($val)) { 49 | throw new \TypeError( 50 | \sprintf('Expected a 1-dimensional array (%s).', $this->index) 51 | ); 52 | } 53 | if (\is_null($val)) { 54 | $data[$key] = ''; 55 | } elseif (\is_numeric($val)) { 56 | $data[$key] = (string) $val; 57 | } elseif (!\is_string($val)) { 58 | throw new \TypeError( 59 | \sprintf('Expected a string at index %s (%s).', $key, $this->index) 60 | ); 61 | } 62 | } 63 | return parent::applyCallbacks($data, 0); 64 | } 65 | return parent::applyCallbacks($data, $offset); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/IndexPolicyTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($any->indexIsValid('foo')); 25 | $this->assertTrue($any->indexIsValid(1)); 26 | $this->assertFalse($any->indexIsValid(1.2)); 27 | $this->assertFalse($any->indexIsValid([])); 28 | $this->assertFalse($any->indexIsValid(null)); 29 | } 30 | 31 | public function testIntegersOnly(): void 32 | { 33 | $int = new IntegersOnly(); 34 | $this->assertTrue($int->indexIsValid(0)); 35 | $this->assertTrue($int->indexIsValid(1)); 36 | $this->assertTrue($int->indexIsValid(-1)); 37 | $this->assertFalse($int->indexIsValid('1')); 38 | $this->assertFalse($int->indexIsValid(1.0)); 39 | $this->assertFalse($int->indexIsValid('foo')); 40 | } 41 | 42 | public function testKeyAllowList(): void 43 | { 44 | $allow = new IndexAllowList('foo', 'bar', 'baz'); 45 | $this->assertTrue($allow->indexIsValid('foo')); 46 | $this->assertTrue($allow->indexIsValid('bar')); 47 | $this->assertTrue($allow->indexIsValid('baz')); 48 | $this->assertFalse($allow->indexIsValid('qux')); 49 | 50 | $allow = new IndexAllowList(1, 2, 3); 51 | $this->assertTrue($allow->indexIsValid(1)); 52 | $this->assertTrue($allow->indexIsValid(2)); 53 | $this->assertTrue($allow->indexIsValid(3)); 54 | $this->assertFalse($allow->indexIsValid(4)); 55 | $this->assertFalse($allow->indexIsValid('1')); 56 | } 57 | 58 | public function testStringsOnly(): void 59 | { 60 | $str = new StringsOnly(); 61 | $this->assertTrue($str->indexIsValid('foo')); 62 | $this->assertTrue($str->indexIsValid('1')); 63 | $this->assertFalse($str->indexIsValid(1)); 64 | $this->assertFalse($str->indexIsValid(1.0)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Filter/FloatFilter.php: -------------------------------------------------------------------------------- 1 | max = $value; 38 | return $this; 39 | } 40 | 41 | /** 42 | * @throws TypeError 43 | */ 44 | public function setMinimumValue(?float $value = null): static 45 | { 46 | $this->min = $value; 47 | return $this; 48 | } 49 | 50 | /** 51 | * Process data using the filter rules. 52 | * 53 | * @param mixed $data 54 | * @return float 55 | * @throws TypeError 56 | * @throws InvalidDataException 57 | */ 58 | #[ReturnTypeWillChange] 59 | public function process(mixed $data = null): float 60 | { 61 | if (is_array($data)) { 62 | throw new TypeError( 63 | sprintf('Unexpected array for float filter (%s).', $this->index) 64 | ); 65 | } 66 | if (is_int($data) || is_float($data)) { 67 | $data = (float) $data; 68 | } elseif (is_null($data) || $data === '') { 69 | $data = null; 70 | } elseif (is_string($data) && is_numeric($data)) { 71 | $data = (float) $data; 72 | } else { 73 | throw new TypeError( 74 | sprintf('Expected an integer or floating point number (%s).', $this->index) 75 | ); 76 | } 77 | 78 | if (!is_null($this->min) && !is_null($data)) { 79 | if ($data < $this->min) { 80 | $data = null; 81 | } 82 | } 83 | if (!is_null($this->max) && !is_null($data)) { 84 | if ($data > $this->max) { 85 | $data = null; 86 | } 87 | } 88 | 89 | return (float) parent::process($data); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Filter/IntArrayFilter.php: -------------------------------------------------------------------------------- 1 | index) 45 | ); 46 | } 47 | /** @var array $data */ 48 | $data = (array) $data; 49 | if (!Util::is1DArray($data)) { 50 | throw new TypeError( 51 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 52 | ); 53 | } 54 | /** 55 | * @var string|int|float|bool|array|null $val 56 | */ 57 | foreach ($data as $key => $val) { 58 | if (is_array($val)) { 59 | throw new TypeError( 60 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 61 | ); 62 | } 63 | if (\is_int($val) || \is_float($val)) { 64 | $data[$key] = (int) $val; 65 | } elseif (is_null($val) || $val === '') { 66 | $data[$key] = $this->default; 67 | } elseif (is_string($val) && \preg_match('#^-?[0-9]+$#', $val)) { 68 | $data[$key] = (int) $val; 69 | } else { 70 | throw new TypeError( 71 | sprintf('Expected an integer at index %s (%s).', $key, $this->index) 72 | ); 73 | } 74 | } 75 | return parent::applyCallbacks($data, 0); 76 | } 77 | return parent::applyCallbacks($data, $offset); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Filter/IntFilter.php: -------------------------------------------------------------------------------- 1 | max = $value; 48 | return $this; 49 | } 50 | 51 | /** 52 | * @throws TypeError 53 | */ 54 | public function setMinimumValue(?int $value = null): static 55 | { 56 | $this->min = $value; 57 | return $this; 58 | } 59 | 60 | /** 61 | * Process data using the filter rules. 62 | * 63 | * @param mixed $data 64 | * @return int 65 | * @throws TypeError 66 | * @throws InvalidDataException 67 | */ 68 | #[ReturnTypeWillChange] 69 | public function process(mixed $data = null): int 70 | { 71 | if (is_array($data)) { 72 | throw new TypeError( 73 | sprintf('Unexpected array for integer filter (%s).', $this->index) 74 | ); 75 | } 76 | if (is_int($data) || \is_float($data)) { 77 | $data = (int) $data; 78 | } elseif (is_null($data) || $data === '') { 79 | $data = null; 80 | } elseif (is_string($data) && \preg_match('#^-?[0-9]+$#', $data)) { 81 | $data = (int) $data; 82 | } else { 83 | throw new TypeError( 84 | sprintf('Expected an integer (%s).', $this->index) 85 | ); 86 | } 87 | 88 | if (!is_null($this->min) && !is_null($data)) { 89 | if ($data < $this->min) { 90 | $data = null; 91 | } 92 | } 93 | if (!is_null($this->max) && !is_null($data)) { 94 | if ($data > $this->max) { 95 | $data = null; 96 | } 97 | } 98 | 99 | return (int) parent::process($data); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Filter/FloatArrayFilter.php: -------------------------------------------------------------------------------- 1 | index) 47 | ); 48 | } 49 | 50 | /** @var array $data */ 51 | $data = (array) $data; 52 | if (!Util::is1DArray($data)) { 53 | throw new \TypeError( 54 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 55 | ); 56 | } 57 | 58 | /** 59 | * @var string|int|float|bool|array|null $val 60 | */ 61 | foreach ($data as $key => $val) { 62 | if (is_array($val)) { 63 | throw new \TypeError( 64 | sprintf('Expected a 1-dimensional array (%s).', $this->index) 65 | ); 66 | } 67 | if (\is_int($val) || \is_float($val)) { 68 | $data[$key] = (float) $val; 69 | } elseif (is_null($val) || $val === '') { 70 | $data[$key] = (float) $this->default; 71 | } elseif (is_string($val) && \is_numeric($val)) { 72 | $data[$key] = (float) $val; 73 | } else { 74 | throw new \TypeError( 75 | sprintf('Expected a float at index %s (%s).', $key, $this->index) 76 | ); 77 | } 78 | } 79 | return parent::applyCallbacks($data, 0); 80 | } 81 | return parent::applyCallbacks($data, $offset); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Filter/Special/EmailAddressFilter.php: -------------------------------------------------------------------------------- 1 | addThisCallback('validateEmailAddress'); 26 | } 27 | 28 | public function setCheckDNS(bool $value): static 29 | { 30 | $this->checkDNS = $value; 31 | return $this; 32 | } 33 | 34 | /** 35 | * @param string $input 36 | * 37 | * @return string 38 | * @throws InvalidDataException 39 | */ 40 | public function validateEmailAddress(string $input): string 41 | { 42 | /** @var string|bool $filtered */ 43 | $filtered = filter_var($input, FILTER_VALIDATE_EMAIL); 44 | if (!is_string($filtered)) { 45 | throw new InvalidDataException('Invalid email address: ' . $input); 46 | } 47 | $pos = strpos($filtered, '@'); 48 | if ($pos === false) { 49 | throw new InvalidDataException('Invalid email address (no @): ' . $input); 50 | } 51 | if (substr_count($filtered, '@') !== 1) { 52 | throw new InvalidDataException('Invalid email address (more than one @): ' . $input); 53 | } 54 | if ($pos === 0) { 55 | throw new InvalidDataException('Invalid email address (no username): ' . $input); 56 | } 57 | /** 58 | * @var string $username 59 | * @var string $domain 60 | */ 61 | list ($username, $domain) = explode('@', $filtered); 62 | if (preg_match('#^\.#', $username) || preg_match('#\.$#', $username)) { 63 | throw new InvalidDataException('Invalid email address (leading or trailing dot): ' . $input); 64 | } 65 | if (str_contains($filtered, '..')) { 66 | throw new InvalidDataException('Invalid email address (consecutive dots): ' . $input); 67 | } 68 | if ($this->checkDNS) { 69 | if (!preg_match('#^\[?' . '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' . '\]?$#', $domain)) { 70 | if (!checkdnsrr($domain, 'MX')) { 71 | throw new InvalidDataException('Invalid email address (no MX record on domain): ' . $input); 72 | } 73 | } 74 | } 75 | 76 | return $filtered; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Filter/StrictArrayFilter.php: -------------------------------------------------------------------------------- 1 | keyType = $keyType; 35 | $this->valueType = $valueType; 36 | } 37 | 38 | /** 39 | * Apply all of the callbacks for this filter. 40 | * 41 | * @throws TypeError 42 | * @throws InvalidDataException 43 | */ 44 | #[ReturnTypeWillChange] 45 | public function applyCallbacks(mixed $data = null, int $offset = 0): array 46 | { 47 | if ($offset === 0) { 48 | if (is_null($data)) { 49 | return parent::applyCallbacks([], 0); 50 | } elseif (!is_array($data)) { 51 | throw new TypeError( 52 | sprintf('Expected an array (%s).', $this->index) 53 | ); 54 | } 55 | $data = (array) $data; 56 | /** 57 | * @var array $data 58 | * @var string|int|float|bool|array|null $value 59 | */ 60 | foreach ($data as $key => $value) { 61 | $keyType = Util::getType($key); 62 | $valType = Util::getType($value); 63 | if ($keyType !== $this->keyType || $valType !== $this->valueType) { 64 | throw new TypeError( 65 | sprintf( 66 | 'Expected an array<%s, %s>. At least one element of <%s, %s> was found (%s[%s] == %s).', 67 | $this->keyType, 68 | $this->valueType, 69 | $keyType, 70 | $valType, 71 | $this->index, 72 | json_encode($key), 73 | json_encode($value) 74 | ) 75 | ); 76 | } 77 | } 78 | 79 | return parent::applyCallbacks($data, 0); 80 | } 81 | return parent::applyCallbacks($data, $offset); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Filter/StringFilter.php: -------------------------------------------------------------------------------- 1 | index) 65 | ); 66 | } 67 | if (\is_string($data)) { 68 | // continue 69 | } elseif (is_object($data) && \method_exists($data, '__toString')) { 70 | $data = (string)$data->__toString(); 71 | } elseif (is_numeric($data)) { 72 | $data = (string)$data; 73 | } elseif (is_null($data)) { 74 | $data = null; 75 | } else { 76 | throw new TypeError( 77 | sprintf('Expected a string (%s).', $this->index) 78 | ); 79 | } 80 | return (string) parent::process($data); 81 | } 82 | 83 | /** 84 | * Set a regular expression pattern that the input string 85 | * must match. 86 | */ 87 | public function setPattern(string $pattern = ''): static 88 | { 89 | if (empty($pattern)) { 90 | $this->pattern = ''; 91 | } else { 92 | $this->pattern = '#' . preg_replace('/([^\\\\])#/', '$1\\#', $pattern) . '#'; 93 | } 94 | return $this; 95 | } 96 | 97 | /** 98 | * Apply all of the callbacks for this filter. 99 | * 100 | * @param mixed $data 101 | * @param int $offset 102 | * @return mixed 103 | * @throws InvalidDataException 104 | * @throws TypeError 105 | */ 106 | #[ReturnTypeWillChange] 107 | public function applyCallbacks(mixed $data = null, int $offset = 0): string 108 | { 109 | if ($offset === 0) { 110 | if (!empty($this->pattern)) { 111 | if (!\preg_match((string) $this->pattern, (string) $data)) { 112 | throw new InvalidDataException( 113 | sprintf('Pattern match failed (%s).', $this->index) 114 | ); 115 | } 116 | } 117 | return parent::applyCallbacks($data, 0); 118 | } 119 | return parent::applyCallbacks($data, $offset); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ionizer 2 | 3 | [![Build Status](https://github.com/paragonie/ionizer/actions/workflows/ci.yml/badge.svg)](https://github.com/paragonie/ionizer/actions) 4 | [![Psalm Status](https://github.com/paragonie/ionizer/actions/workflows/psalm.yml/badge.svg)](https://github.com/paragonie/ionizere/actions) 5 | [![Latest Stable Version](https://poser.pugx.org/paragonie/ionizer/v/stable)](https://packagist.org/packages/paragonie/ionizer) 6 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/ionizer/v/unstable)](https://packagist.org/packages/paragonie/ionizer) 7 | [![License](https://poser.pugx.org/paragonie/ionizer/license)](https://packagist.org/packages/paragonie/ionizer) 8 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/ionizer.svg)](https://packagist.org/packages/paragonie/ionizer) 9 | 10 | Ionizer provides strict typing and input validation for dynamic inputs (i.e. HTTP request parameters). 11 | **Requires PHP 8.1 or higher.** 12 | 13 | For PHP 7 and 8.0 support, please refer to the [v1.x](https://github.com/paragonie/ionizer/tree/v1.x) branch. 14 | 15 | ## What is Ionizer? 16 | 17 | Ionizer is a structured input filtering system ideal for HTTP form data. 18 | 19 | ### Why is Ionizer important? 20 | 21 | Aside from the benefits of being able to strictly type your applications that accept user input, 22 | Ionizer makes it easy to mitigate [some NoSQL injection techniques](https://www.php.net/manual/en/mongodb.security.request_injection.php). 23 | Learn more about [preventing NoSQL injection with Ionizer](docs/nosql-injection-prevention.md). 24 | 25 | ## Installing 26 | 27 | Get Composer, then run the following: 28 | 29 | ```terminal 30 | composer require paragonie/ionizer 31 | ``` 32 | 33 | ## Usage 34 | 35 | ```php 36 | addFilter( 47 | 'username', 48 | (new StringFilter())->setPattern('^[A-Za-z0-9_\-]{3,24}$') 49 | ) 50 | ->addFilter('passphrase', new StringFilter()) 51 | ->addFilter( 52 | 'domain', 53 | new AllowList('US-1', 'US-2', 'EU-1', 'EU-2') 54 | ); 55 | 56 | // Invoke the filter container on the array to get the filtered result: 57 | try { 58 | // $post passed all of our filters. 59 | $post = $ic($_POST); 60 | } catch (\TypeError $ex) { 61 | // Invalid data provided. 62 | } 63 | ``` 64 | 65 | Ionizer can even specify structured input with some caveats. 66 | 67 | ```php 68 | addFilter('numbers', new IntArrayFilter()) 81 | ->addFilter('strings', new StringArrayFilter()) 82 | 83 | // You can also specify subkeys, separated by a period: 84 | ->addFilter('user.name', new StringFilter()) 85 | ->addFilter('user.unixtime', new IntFilter()); 86 | 87 | $input = [ 88 | 'numbers' => [1, 2, 3], 89 | 'strings' => ['a', 'b'], 90 | 'user' => [ 91 | 'name' => 'test', 92 | 'unixtime' => time() 93 | ] 94 | ]; 95 | 96 | try { 97 | $valid = $ic($input); 98 | } catch (\TypeError $ex) { 99 | } 100 | ``` 101 | 102 | ## Support Contracts 103 | 104 | If your company uses this library in their products or services, you may be 105 | interested in [purchasing a support contract from Paragon Initiative Enterprises](https://paragonie.com/enterprise). 106 | -------------------------------------------------------------------------------- /docs/nosql-injection-prevention.md: -------------------------------------------------------------------------------- 1 | # Preventing NoSQL Injection with Ionizer 2 | 3 | NoSQL databases like MongoDB are powerful, but they can be vulnerable to injection attacks if user input is not handled 4 | carefully. This document explains how "request injection" attacks work in PHP with MongoDB and how to use Ionizer to 5 | prevent them. 6 | 7 | ## The Vulnerability: Request Injection 8 | 9 | When building queries for MongoDB in PHP, it's common to use associative arrays. For example, to find a user by their 10 | username, you might construct a query like this: 11 | 12 | ```php 13 | $_GET['username']]); 15 | ``` 16 | 17 | > ![NOTE] 18 | > Professional developers will not recklessly handle superglobals like this and expect to be secure, but it's a good, 19 | > simplified example to work with. In practice, the avenues for setting up this attack are more subtle. 20 | 21 | If a user visits `http://example.com?username=alice`, the query becomes `['username' => 'alice']`. All is well so far. 22 | 23 | However, PHP has a feature where it can parse query string parameters with square brackets into nested arrays. An 24 | attacker can exploit this. For example, if they craft a URL like this: 25 | 26 | `http://example.com?username[$ne]=foo` 27 | 28 | PHP will parse `$_GET['username']` into `['$ne' => 'foo']`. Your MongoDB query then becomes: 29 | 30 | ```php 31 | ['$ne' => 'foo']]); 33 | ``` 34 | 35 | This query will select all documents where the `username` is **not equal to** `foo`. This could potentially return all 36 | users in your database, leading to a data leak. This is a form of NoSQL injection. 37 | 38 | ## The Solution: Strict Input Validation with Ionizer 39 | 40 | The best way to prevent this type of vulnerability is to strictly validate all user input before it's used in a database 41 | query. You need to ensure that the data is of the expected type and format. 42 | 43 | Ionizer is a library that makes this easy. It allows you to define a set of filters for your expected input. If the 44 | input doesn't match the filters, Ionizer will throw an exception, and you can reject the request. 45 | 46 | ### Example: Using Ionizer to Sanitize Query Parameters 47 | 48 | Here's how you can use Ionizer to protect the example above: 49 | 50 | ```php 51 | addFilter( 59 | 'username', 60 | // We can also add a regex pattern for the username format. 61 | (new StringFilter())->setPattern('^[A-Za-z0-9_\-]{3,24}$') 62 | ); 63 | 64 | try { 65 | // 2. Process the input against the filters. 66 | // Ionizer will ensure $_GET contains a 'username' key, and its value is a string. 67 | // If $_GET['username'] is an array (like in the attack scenario), 68 | // a TypeError will be thrown. 69 | $filteredInput = $filterContainer($_GET); 70 | 71 | // 3. Use the sanitized input in your query. 72 | $query = new \MongoDB\Driver\Query(['username' => $filteredInput['username']]); 73 | 74 | // ... proceed to execute the query safely. 75 | 76 | } catch (\TypeError $ex) { 77 | // 4. Handle invalid input. 78 | // The input did not match our filter rules. 79 | // Log the error and return an appropriate HTTP response (e.g., 400 Bad Request). 80 | header("HTTP/1.1 400 Bad Request"); 81 | echo "Invalid input."; 82 | exit; 83 | } 84 | ``` 85 | 86 | By using Ionizer to validate that `username` is a string, you prevent the attacker from injecting a malicious array 87 | into your MongoDB query, effectively mitigating the request injection vulnerability. 88 | -------------------------------------------------------------------------------- /src/Filter/AllowList.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | protected array $allowedValues = []; 19 | 20 | /** 21 | * AllowList constructor. 22 | * @param scalar ...$values 23 | */ 24 | public function __construct(...$values) 25 | { 26 | $this->addToWhiteList(...$values); 27 | } 28 | 29 | protected function addToWhiteList(mixed ...$values): static 30 | { 31 | switch ($this->type) { 32 | case 'bool': 33 | /** 34 | * @var array $values 35 | * @var bool $val 36 | */ 37 | foreach ($values as $val) { 38 | $this->allowedValues []= (bool) $val; 39 | } 40 | break; 41 | case 'float': 42 | /** 43 | * @var array $values 44 | * @var float $val 45 | */ 46 | foreach ($values as $val) { 47 | $this->allowedValues []= (float) $val; 48 | } 49 | break; 50 | case 'int': 51 | /** 52 | * @var array $values 53 | * @var int $val 54 | */ 55 | foreach ($values as $val) { 56 | $this->allowedValues []= (int) $val; 57 | } 58 | break; 59 | case 'string': 60 | /** 61 | * @var array $values 62 | * @var string $val 63 | */ 64 | foreach ($values as $val) { 65 | $this->allowedValues []= (string) $val; 66 | } 67 | break; 68 | default: 69 | /** 70 | * @var array $values 71 | * @var string $val 72 | */ 73 | foreach ($values as $val) { 74 | $this->allowedValues []= $val; 75 | } 76 | } 77 | return $this; 78 | } 79 | 80 | /** 81 | * Process data using the filter rules. 82 | * 83 | * @throws TypeError 84 | * @throws InvalidDataException 85 | */ 86 | public function process(mixed $data = null): mixed 87 | { 88 | if (!empty($this->allowedValues)) { 89 | if (!\in_array($data, $this->allowedValues, true)) { 90 | $data = null; 91 | } 92 | } 93 | 94 | /** @var string|int|float|bool|null $data */ 95 | $data = $this->applyCallbacks($data, 0); 96 | if ($data === null) { 97 | /** @var string|int|float|bool|null $data */ 98 | $data = $this->default; 99 | } 100 | 101 | // For type strictness: 102 | switch ($this->type) { 103 | case 'bool': 104 | /** @var bool $data */ 105 | return (bool) $data; 106 | case 'float': 107 | /** @var float $data */ 108 | return (float) $data; 109 | case 'int': 110 | /** @var int $data */ 111 | return (int) $data; 112 | case 'string': 113 | /** @var string $data */ 114 | return (string) $data; 115 | default: 116 | return $data; 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/InputFilter.php: -------------------------------------------------------------------------------- 1 | type = $typeIndicator; 46 | return $this; 47 | } 48 | 49 | /** 50 | * Throw an InvalidDataException is this field is not defined/populated. 51 | * Use in a callback. 52 | * 53 | * $filter->addCallback([InputFilter::class, 'required']); 54 | * 55 | * @param mixed|null $data 56 | * @return mixed 57 | * @throws InvalidDataException 58 | */ 59 | public static function required($data = null) 60 | { 61 | if (\is_null($data)) { 62 | throw new InvalidDataException('This is not an optional field.'); 63 | } 64 | return $data; 65 | } 66 | 67 | /** 68 | * Set the default value (not applicable to booleans) 69 | * 70 | * @param string|int|float|bool|array|null $value 71 | * @return FilterInterface 72 | */ 73 | public function setDefault($value): FilterInterface 74 | { 75 | $this->default = $value; 76 | return $this; 77 | } 78 | 79 | /** 80 | * Add a callback to this filter (supports more than one) 81 | * 82 | * @param callable $func 83 | * @return FilterInterface 84 | */ 85 | public function addCallback(callable $func): FilterInterface 86 | { 87 | $this->callbacks[] = $func; 88 | return $this; 89 | } 90 | 91 | /** 92 | * Add a callback to this filter (supports more than one) 93 | * 94 | * @param string $func 95 | * @return FilterInterface 96 | */ 97 | public function addThisCallback(string $func): FilterInterface 98 | { 99 | if (!\method_exists($this, $func)) { 100 | throw new \Error('Method ' . $func . ' does not exist on class ' . \get_class($this)); 101 | } 102 | $this->thisCallbacks[] = $func; 103 | return $this; 104 | } 105 | 106 | /** 107 | * Process data using the filter rules. 108 | * 109 | * @param mixed $data 110 | * @return mixed 111 | * @throws InvalidDataException 112 | * @throws TypeError 113 | */ 114 | public function process(mixed $data = null): mixed 115 | { 116 | /** @var string|int|float|bool|array|null $data */ 117 | $data = $this->applyCallbacks($data, 0); 118 | /** @var string|int|float|bool|array|null $data */ 119 | $data = $this->applyThisCallbacks($data, 0); 120 | if ($data === null) { 121 | /** @var string|int|float|bool|array|null $data */ 122 | $data = $this->default; 123 | } 124 | 125 | // For type strictness: 126 | switch ($this->type) { 127 | case 'array': 128 | /** @var array $data */ 129 | return (array) $data; 130 | case 'bool': 131 | /** @var bool $data */ 132 | return (bool) $data; 133 | case 'float': 134 | /** @var float $data */ 135 | return (float) $data; 136 | case 'int': 137 | /** @var int $data */ 138 | return (int) $data; 139 | case 'string': 140 | /** @var string $data */ 141 | return (string) $data; 142 | default: 143 | return $data; 144 | } 145 | } 146 | 147 | /** 148 | * Apply all of the callbacks for this filter. 149 | * 150 | * @param mixed $data 151 | * @param int $offset 152 | * @return mixed 153 | * @throws InvalidDataException 154 | */ 155 | public function applyCallbacks($data = null, int $offset = 0) 156 | { 157 | if (empty($data)) { 158 | if ($this->type === 'bool') { 159 | return false; 160 | } 161 | return $this->default; 162 | } 163 | if ($offset >= \count($this->callbacks)) { 164 | return $data; 165 | } 166 | $func = $this->callbacks[$offset]; 167 | /** @var string|int|float|bool|array|null $data */ 168 | $data = $func($data); 169 | return $this->applyCallbacks($data, $offset + 1); 170 | } 171 | /** 172 | * Apply all of the callbacks for this filter. 173 | * 174 | * @param mixed $data 175 | * @param int $offset 176 | * @return mixed 177 | * @throws InvalidDataException 178 | */ 179 | public function applyThisCallbacks($data = null, int $offset = 0) 180 | { 181 | if ($offset >= \count($this->thisCallbacks)) { 182 | return $data; 183 | } 184 | /** @var string $func */ 185 | $func = $this->thisCallbacks[$offset]; 186 | /** @var string|int|float|bool|array|null $data */ 187 | $data = $this->$func($data); 188 | return $this->applyCallbacks($data, $offset + 1); 189 | } 190 | 191 | /** 192 | * @param string $index 193 | * @return FilterInterface 194 | */ 195 | public function setIndex(string $index): FilterInterface 196 | { 197 | $this->index = $index; 198 | return $this; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/InputFilterContainer.php: -------------------------------------------------------------------------------- 1 | > 25 | */ 26 | protected $filterMap = []; 27 | 28 | /** 29 | * InputFilterContainer constructor. 30 | */ 31 | abstract public function __construct(); 32 | 33 | /** 34 | * Add a new filter to this input value 35 | * 36 | * @param string $path 37 | * @param FilterInterface $filter 38 | * @return FilterContainerInterface 39 | */ 40 | public function addFilter( 41 | string $path, 42 | FilterInterface $filter 43 | ): FilterContainerInterface { 44 | if (!isset($this->filterMap[$path])) { 45 | $this->filterMap[$path] = []; 46 | } 47 | /** @psalm-suppress MixedArrayAssignment */ 48 | $this->filterMap[$path][] = $filter->setIndex($path); 49 | return $this; 50 | } 51 | 52 | /** 53 | * @param string $path 54 | * @return array 55 | */ 56 | public function getFiltersForPath(string $path) 57 | { 58 | if (!isset($this->filterMap[$path])) { 59 | $this->filterMap[$path] = []; 60 | } 61 | return $this->filterMap[$path]; 62 | } 63 | 64 | /** 65 | * Use firstlevel.second_level.thirdLevel to find indices in an array 66 | * 67 | * @param string $key 68 | * @param mixed $multiDimensional 69 | * @return mixed 70 | * @throws \Error 71 | * @throws InvalidDataException 72 | * 73 | * @psalm-suppress UnusedVariable 74 | */ 75 | public function filterValue(string $key, $multiDimensional) 76 | { 77 | /** @var array $pieces */ 78 | $pieces = Util::chunk($key, (string) static::SEPARATOR); 79 | /** @var array|string $filtered */ 80 | $filtered =& $multiDimensional; 81 | 82 | $var = '$multiDimensional'; 83 | if (\is_array($multiDimensional)) { 84 | foreach ($pieces as $piece) { 85 | $_var = substr($var, 1); 86 | if (empty(${$_var})) { 87 | ${$_var} = []; 88 | } 89 | ksort(${$_var}); 90 | 91 | $append = '[' . self::sanitize($piece) . ']'; 92 | if (!isset(${$var . $append})) { 93 | ${$var . $append} = null; 94 | } 95 | $var .= $append; 96 | } 97 | /** 98 | * @security This shouldn't be escapable. We know eval is evil, but 99 | * there's not a more elegant way to process this in PHP. 100 | */ 101 | eval('$filtered =& ' . $var. ';'); 102 | } 103 | 104 | // If we have filters, let's apply them: 105 | if (isset($this->filterMap[$key])) { 106 | /** @var object|null $filter */ 107 | foreach ($this->filterMap[$key] as $filter) { 108 | if ($filter instanceof FilterInterface) { 109 | /** @var string|int|bool|float|array $filtered */ 110 | $filtered = $filter->process($filtered); 111 | } 112 | } 113 | } 114 | return $multiDimensional; 115 | } 116 | 117 | /** 118 | * Use firstlevel.second_level.thirdLevel to find indices in an array 119 | * 120 | * Doesn't apply filters 121 | * 122 | * @param string $key 123 | * @param array $multiDimensional 124 | * @return mixed 125 | * @psalm-suppress PossiblyInvalidArrayOffset 126 | */ 127 | public function getUnfilteredValue(string $key, array $multiDimensional = []) 128 | { 129 | /** @var array $pieces */ 130 | $pieces = Util::chunk($key, (string) static::SEPARATOR); 131 | 132 | /** @var string|array $value */ 133 | $value = $multiDimensional; 134 | 135 | /** 136 | * @var array $pieces 137 | * @var string $piece 138 | */ 139 | foreach ($pieces as $piece) { 140 | if (!isset($value[$piece])) { 141 | return null; 142 | } 143 | /** @var string|array $next */ 144 | $next = $value[$piece]; 145 | 146 | /** @var string|array $value */ 147 | $value = $next; 148 | } 149 | return $value; 150 | } 151 | 152 | /** 153 | * Only allow allow printable ASCII characters: 154 | * 155 | * @param string $input 156 | * @return string 157 | * @throws \Error 158 | */ 159 | protected static function sanitize(string $input): string 160 | { 161 | /** @var string|bool $sanitized */ 162 | $sanitized = \json_encode( 163 | \preg_replace('#[^\x20-\x7e]#', '', $input) 164 | ); 165 | if (!\is_string($sanitized)) { 166 | throw new \Error('Could not sanitize string'); 167 | } 168 | return $sanitized; 169 | } 170 | 171 | /** 172 | * Process the input array. 173 | * 174 | * @param array $dataInput 175 | * @return array 176 | * @throws \Error 177 | * @throws InvalidDataException 178 | */ 179 | public function __invoke(array $dataInput = []): array 180 | { 181 | /** @var string $key */ 182 | foreach (\array_keys($this->filterMap) as $key) { 183 | /** @var array $dataInput */ 184 | $dataInput = $this->filterValue($key, $dataInput); 185 | } 186 | return $dataInput; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /tests/FilterTest.php: -------------------------------------------------------------------------------- 1 | addFilter('test1', new BoolFilter()) 35 | ->addFilter('test2', new BoolFilter()); 36 | 37 | if (!($filter instanceof GeneralFilterContainer)) { 38 | $this->fail('Type error'); 39 | } 40 | 41 | $before = [ 42 | 'test1' => 1, 43 | 'test2' => 0 44 | ]; 45 | $after = $filter($before); 46 | 47 | $this->assertSame( 48 | [ 49 | 'test1' => true, 50 | 'test2' => false 51 | ], 52 | $after 53 | ); 54 | 55 | try { 56 | $typeError = [ 57 | 'test1' => true, 58 | 'test2' => [] 59 | ]; 60 | $filter($typeError); 61 | $this->fail('Expected a TypeError'); 62 | } catch (\TypeError $ex) { 63 | } 64 | } 65 | 66 | /** 67 | * @throws Error 68 | * @throws InvalidDataException 69 | */ 70 | public function testFloatFilter(): void 71 | { 72 | $filter = (new GeneralFilterContainer()) 73 | ->addFilter('test1', new FloatFilter()) 74 | ->addFilter('test2', new FloatFilter()) 75 | ->addFilter('test3', new FloatFilter()); 76 | 77 | if (!($filter instanceof GeneralFilterContainer)) { 78 | $this->fail('Type error'); 79 | } 80 | 81 | $before = [ 82 | 'test1' => '22.7', 83 | 'test2' => null, 84 | 'test3' => M_E 85 | ]; 86 | $after = $filter($before); 87 | 88 | $this->assertSame( 89 | [ 90 | 'test1' => 22.7, 91 | 'test2' => 0.0, 92 | 'test3' => M_E 93 | ], 94 | $after 95 | ); 96 | 97 | try { 98 | $typeError = [ 99 | 'test1' => '22', 100 | 'test2' => 0, 101 | 'test3' => [] 102 | ]; 103 | $filter($typeError); 104 | $this->fail('Expected a TypeError'); 105 | } catch (\TypeError $ex) { 106 | } 107 | 108 | $filter->addFilter( 109 | 'test4', 110 | (new FloatFilter()) 111 | ->setMinimumValue(1.5) 112 | ->setMaximumValue(2.47) 113 | ); 114 | 115 | $this->assertSame( 116 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 0.0], 117 | $filter(['test4' => 1.4]) 118 | ); 119 | $this->assertSame( 120 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 0.0], 121 | $filter(['test4' => 1.499]) 122 | ); 123 | $this->assertSame( 124 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 1.5], 125 | $filter(['test4' => 1.5]) 126 | ); 127 | $this->assertSame( 128 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 2.0], 129 | $filter(['test4' => 2.0]) 130 | ); 131 | $this->assertSame( 132 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 2.45], 133 | $filter(['test4' => 2.45]) 134 | ); 135 | $this->assertSame( 136 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 2.47], 137 | $filter(['test4' => 2.47]) 138 | ); 139 | $this->assertSame( 140 | ['test1' => 0.0,'test2' => 0.0,'test3' => 0.0, 'test4' => 0.0], 141 | $filter(['test4' => 2.471]) 142 | ); 143 | } 144 | 145 | /** 146 | * @throws Error 147 | * @throws InvalidDataException 148 | */ 149 | public function testIntFilter(): void 150 | { 151 | $filter = (new GeneralFilterContainer()) 152 | ->addFilter('test1', new IntFilter()) 153 | ->addFilter('test2', new IntFilter()) 154 | ->addFilter('test3', new IntFilter()); 155 | 156 | if (!($filter instanceof GeneralFilterContainer)) { 157 | $this->fail('Type error'); 158 | } 159 | 160 | $before = [ 161 | 'test1' => '22', 162 | 'test2' => null, 163 | 'test3' => PHP_INT_MAX 164 | ]; 165 | $after = $filter($before); 166 | 167 | $this->assertSame( 168 | [ 169 | 'test1' => 22, 170 | 'test2' => 0, 171 | 'test3' => PHP_INT_MAX 172 | ], 173 | $after 174 | ); 175 | 176 | try { 177 | $typeError = [ 178 | 'test1' => '22', 179 | 'test2' => 0, 180 | 'test3' => [] 181 | ]; 182 | $filter($typeError); 183 | $this->fail('Expected a TypeError'); 184 | } catch (\TypeError $ex) { 185 | } 186 | 187 | 188 | try { 189 | $typeError = [ 190 | 'test1' => '22', 191 | 'test2' => 0, 192 | 'test3' => '1.5' 193 | ]; 194 | $filter($typeError); 195 | $this->fail('Expected a TypeError'); 196 | } catch (\TypeError $ex) { 197 | } 198 | 199 | 200 | $filter->addFilter( 201 | 'test4', 202 | (new IntFilter()) 203 | ->setMinimumValue(15) 204 | ->setMaximumValue(247) 205 | ); 206 | 207 | $this->assertSame( 208 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 0], 209 | $filter(['test4' => 13]) 210 | ); 211 | $this->assertSame( 212 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 0], 213 | $filter(['test4' => 14]) 214 | ); 215 | $this->assertSame( 216 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 15], 217 | $filter(['test4' => 15]) 218 | ); 219 | $this->assertSame( 220 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 20], 221 | $filter(['test4' => 20]) 222 | ); 223 | $this->assertSame( 224 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 245], 225 | $filter(['test4' => 245]) 226 | ); 227 | $this->assertSame( 228 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 247], 229 | $filter(['test4' => 247]) 230 | ); 231 | $this->assertSame( 232 | ['test1' => 0,'test2' => 0,'test3' => 0, 'test4' => 0], 233 | $filter(['test4' => 248]) 234 | ); 235 | } 236 | 237 | /** 238 | * @throws Error 239 | * @throws InvalidDataException 240 | */ 241 | public function testStringFilter(): void 242 | { 243 | $filter = (new GeneralFilterContainer()) 244 | ->addFilter('test1', new StringFilter()) 245 | ->addFilter('test2', new StringFilter()) 246 | ->addFilter('test3', new StringFilter()); 247 | 248 | if (!($filter instanceof GeneralFilterContainer)) { 249 | $this->fail('Type error'); 250 | } 251 | 252 | $before = [ 253 | 'test1' => 22.7, 254 | 'test2' => null, 255 | 'test3' => 'abcdefg' 256 | ]; 257 | $after = $filter($before); 258 | 259 | $this->assertSame( 260 | [ 261 | 'test1' => '22.7', 262 | 'test2' => '', 263 | 'test3' => 'abcdefg' 264 | ], 265 | $after 266 | ); 267 | 268 | try { 269 | $typeError = [ 270 | 'test1' => '22', 271 | 'test2' => 0, 272 | 'test3' => [] 273 | ]; 274 | $filter($typeError); 275 | $this->fail('Expected a TypeError'); 276 | } catch (\TypeError $ex) { 277 | } 278 | } 279 | 280 | /** 281 | * @throws InvalidDataException 282 | */ 283 | public function testStringRegex(): void 284 | { 285 | $filter = new GeneralFilterContainer(); 286 | $filter->addFilter( 287 | 'test1', 288 | (new StringFilter())->setPattern('^[a-z]+$') 289 | ); 290 | $after = $filter(['test1' => 'abcdef']); 291 | 292 | $this->assertSame( 293 | ['test1' => 'abcdef'], 294 | $after 295 | ); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /tests/SpecialTest.php: -------------------------------------------------------------------------------- 1 | addFilter('cc', new CreditCardNumberFilter()); 33 | 34 | if (!($filter instanceof GeneralFilterContainer)) { 35 | $this->fail('Type error'); 36 | } 37 | 38 | $this->assertSame( 39 | ['cc' => '4242424242424242'], 40 | $filter(['cc' => '4242424242424242']), 41 | 'Stripe standard credit card number test vector failed.' 42 | ); 43 | $this->assertSame( 44 | ['cc' => '4242424242424242'], 45 | $filter(['cc' => '4242-4242-4242-4242']), 46 | 'Hyphens. Hyphens, everywhere.' 47 | ); 48 | $this->assertSame( 49 | ['cc' => '4242424242424242'], 50 | $filter(['cc' => '4242 4242 4242 4242']), 51 | 'Atmosphere. Black holes. Astronauts. Nebulas. Jupiter. The Big Dipper.' 52 | ); 53 | } 54 | 55 | /** 56 | * @throws Error 57 | * @throws InvalidDataException 58 | */ 59 | public function testDateTimeFilter(): void 60 | { 61 | $filter = (new GeneralFilterContainer()) 62 | ->addFilter( 63 | 'dob', new DateTimeFilter( 64 | 'm/d/Y', 65 | new DateTimeZone('Etc/GMT') 66 | ) 67 | )->addFilter( 68 | 'published', 69 | new DateTimeFilter( 70 | DateTime::ATOM, 71 | new DateTimeZone('Etc/GMT') 72 | ) 73 | )->addFilter( 74 | 'chicago', 75 | new DateTimeFilter( 76 | DateTime::ATOM, 77 | new DateTimeZone('America/Chicago') 78 | ) 79 | )->addFilter( 80 | 'london', 81 | new DateTimeFilter( 82 | DateTime::ATOM, 83 | new DateTimeZone('Europe/London') 84 | ) 85 | )->addFilter( 86 | 'newyork', 87 | new DateTimeFilter( 88 | DateTime::ATOM, 89 | new DateTimeZone('America/New_York') 90 | ) 91 | ); 92 | 93 | if (!($filter instanceof GeneralFilterContainer)) { 94 | $this->fail('Type error'); 95 | } 96 | $testCases = $this->getDateTimeTestCases(); 97 | foreach ($testCases as $index => $tc) { 98 | list($before, $after) = $tc; 99 | $this->assertEquals($after, $filter($before), 'index: ' . $index); 100 | } 101 | } 102 | 103 | /** 104 | * @return array 105 | */ 106 | private function getDateTimeTestCases(): array 107 | { 108 | return [ 109 | [ 110 | [ 111 | 'chicago' => '1970-01-01', 112 | 'dob' => '1970-01-01', 113 | 'london' => '1970-01-01', 114 | 'newyork' => '1970-01-01', 115 | 'published' => '1970-01-01' 116 | ], 117 | [ 118 | 'chicago' => '1970-01-01T00:00:00-06:00', 119 | 'dob' => '01/01/1970', 120 | 'london' => '1970-01-01T00:00:00+01:00', 121 | 'newyork' => '1970-01-01T00:00:00-05:00', 122 | 'published' => '1970-01-01T00:00:00+00:00' 123 | ] 124 | ], [ 125 | [ 126 | 'chicago' => '12/25/2017', 127 | 'dob' => '12/25/2017', 128 | 'london' => '12/25/2017', 129 | 'newyork' => '12/25/2017', 130 | 'published' => '12/25/2017' 131 | ], 132 | [ 133 | 'chicago' => '2017-12-25T00:00:00-06:00', 134 | 'dob' => '12/25/2017', 135 | 'london' => '2017-12-25T00:00:00+00:00', 136 | 'newyork' => '2017-12-25T00:00:00-05:00', 137 | 'published' => '2017-12-25T00:00:00+00:00' 138 | ] 139 | ], [ 140 | [ 141 | 'chicago' => '1991-02-29', 142 | 'dob' => '1991-02-29', 143 | 'london' => '1991-02-29', 144 | 'newyork' => '1991-02-29', 145 | 'published' => '1991-02-29' 146 | ], 147 | [ 148 | 'chicago' => '1991-03-01T00:00:00-06:00', 149 | 'dob' => '03/01/1991', 150 | 'london' => '1991-03-01T00:00:00+00:00', 151 | 'newyork' => '1991-03-01T00:00:00-05:00', 152 | 'published' => '1991-03-01T00:00:00+00:00' 153 | ] 154 | ], [ 155 | [ 156 | 'chicago' => '1992-02-29', 157 | 'dob' => '1992-02-29', 158 | 'london' => '1992-02-29', 159 | 'newyork' => '1992-02-29', 160 | 'published' => '1992-02-29' 161 | ], 162 | [ 163 | 'chicago' => '1992-02-29T00:00:00-06:00', 164 | 'dob' => '02/29/1992', 165 | 'london' => '1992-02-29T00:00:00+00:00', 166 | 'newyork' => '1992-02-29T00:00:00-05:00', 167 | 'published' => '1992-02-29T00:00:00+00:00' 168 | ] 169 | ], [ 170 | [ 171 | 'chicago' => '12/25/2017 11:33 AM', 172 | 'dob' => '12/25/2017 11:33 AM', 173 | 'london' => '12/25/2017 11:33 AM', 174 | 'newyork' => '12/25/2017 11:33 AM', 175 | 'published' => '12/25/2017 11:33 AM' 176 | ], 177 | [ 178 | 'chicago' => '2017-12-25T11:33:00-06:00', 179 | 'dob' => '12/25/2017', 180 | 'london' => '2017-12-25T11:33:00+00:00', 181 | 'newyork' => '2017-12-25T11:33:00-05:00', 182 | 'published' => '2017-12-25T11:33:00+00:00' 183 | ] 184 | ] 185 | ]; 186 | } 187 | 188 | /** 189 | * @throws Error 190 | * @throws InvalidDataException 191 | */ 192 | public function testEmailAddressFilter(): void 193 | { 194 | $emailFilter = (new EmailAddressFilter()); 195 | $filter = (new GeneralFilterContainer()) 196 | ->addFilter('email', $emailFilter); 197 | 198 | if (!($filter instanceof GeneralFilterContainer)) { 199 | $this->fail('Type error'); 200 | } 201 | $this->assertSame( 202 | ['email' => 'test@localhost.us'], 203 | $filter(['email' => 'test@localhost.us']) 204 | ); 205 | $valid = [ 206 | 'email@domain.com', 207 | 'firstname.lastname@domain.com', 208 | 'email@subdomain.domain.com', 209 | 'firstname+lastname@domain.com', 210 | 'email@[123.123.123.123]', 211 | '"email"@domain.com', 212 | '1234567890@domain.com', 213 | 'email@paragonie.com', 214 | '_______@domain.com', 215 | 'email@domain.name', 216 | // 'email@domain.co.jp', 217 | 'firstname-lastname@domain.com' 218 | ]; 219 | 220 | foreach ($valid as $in) { 221 | // Don't throw an exception 222 | $filter(['email' => $in]); 223 | } 224 | 225 | $invalid = [ 226 | 'plainaddress', 227 | '#@%^%#$@#$@#.com', 228 | '@domain.com', 229 | 'email.domain.com', 230 | 'email@domain@domain.com', 231 | '.email@domain.com', 232 | 'email.@domain.com', 233 | 'email..email@domain.com', 234 | 'あいうえお@domain.com', 235 | 'email@domain.com (Joe Smith)', 236 | 'email@domain', 237 | 'email@-domain.com', 238 | 'email@domain.web', 239 | 'email@111.222.333.44444', 240 | 'email@domain..com' 241 | ]; 242 | foreach ($invalid as $in) { 243 | try { 244 | $filter(['email' => $in]); 245 | $this->fail('Invalid email address accepted: ' . $in); 246 | } catch (InvalidDataException $ex) { 247 | } 248 | } 249 | 250 | // Optional: Disable DNS check 251 | $noDNSfilter = (new GeneralFilterContainer()) 252 | ->addFilter('email', (new EmailAddressFilter())->setCheckDNS(false)); 253 | if (!($noDNSfilter instanceof GeneralFilterContainer)) { 254 | $this->fail('Type error'); 255 | } 256 | 257 | try { 258 | $filter(['email' => 'email@domain.web']); 259 | $this->fail('Invalid email address accepted: ' . 'email@domain.web'); 260 | } catch (InvalidDataException $ex) { 261 | } 262 | $noDNSfilter(['email' => 'email@domain.web']); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Ionizer Developer Documentation 2 | 3 | This document provides detailed examples for the different input filters available in Ionizer. 4 | 5 | **Other Documents** 6 | 7 | * [Preventing NoSQL Injection with Ionizer](nosql-injection-prevention.md) 8 | 9 | **Contents of This Document** 10 | 11 | * [Scalar Type Filters](#scalar-type-filters) 12 | * [`BoolFilter`](#boolfilter) 13 | * [`FloatFilter`](#floatfilter) 14 | * [`IntFilter`](#intfilter) 15 | * [`StringFilter`](#stringfilter) 16 | * [Array Filters](#array-filters) 17 | * [`BoolArrayFilter`](#boolarrayfilter) 18 | * [`FloatArrayFilter`](#floatarrayfilter) 19 | * [`IntArrayFilter`](#intarrayfilter) 20 | * [`StringArrayFilter`](#stringarrayfilter) 21 | * [Other Filters](#other-filters) 22 | * [`AllowList`](#allowlist) 23 | * [`EmailAddressFilter`](#emailaddressfilter) 24 | 25 | ## Scalar Type Filters 26 | 27 | Below is the documentation for scalar type filters available in Ionizer. 28 | 29 | ### `BoolFilter` 30 | 31 | The `BoolFilter` validates a single boolean value. 32 | 33 | **Example:** 34 | ```php 35 | addFilter('is_active', new BoolFilter()); 41 | 42 | $input = ['is_active' => true]; 43 | 44 | try { 45 | $valid = $ic($input); 46 | } catch (\TypeError $ex) { 47 | // Handle error 48 | } 49 | ``` 50 | 51 | ### `FloatFilter` 52 | 53 | The `FloatFilter` validates a single float value. 54 | 55 | **Example:** 56 | ```php 57 | addFilter('price', new FloatFilter()); 63 | 64 | $input = ['price' => 123.45]; 65 | 66 | try { 67 | $valid = $ic($input); 68 | } catch (\TypeError $ex) { 69 | // Handle error 70 | } 71 | ``` 72 | 73 | ### `IntFilter` 74 | 75 | The `IntFilter` validates a single integer value. 76 | 77 | **Example:** 78 | ```php 79 | addFilter('user_id', new IntFilter()); 85 | 86 | $input = ['user_id' => 12345]; 87 | 88 | try { 89 | $valid = $ic($input); 90 | } catch (\TypeError $ex) { 91 | // Handle error 92 | } 93 | ``` 94 | 95 | ### `StringFilter` 96 | 97 | The `StringFilter` is used to validate a string. You can set a regex pattern to validate against. 98 | 99 | **Example:** 100 | ```php 101 | addFilter( 107 | 'username', 108 | (new StringFilter())->setPattern('^[A-Za-z0-9_\-]{3,24}$') 109 | ); 110 | 111 | $input = ['username' => 'my_valid_username']; 112 | $invalid = ['username' => 'invalid-username!']; 113 | 114 | try { 115 | $valid = $ic($input); // OK 116 | $ic($invalid); // Throws TypeError 117 | } catch (\TypeError $ex) { 118 | // Handle error 119 | } 120 | ``` 121 | 122 | ## Array Filters 123 | 124 | Sometimes you need to accept a list of values, rather than a single value. These input filters allow you to limit the 125 | inputs to a flat, one-dimensional array consisting of specific values. 126 | 127 | By default, the array filters only examine the **values, not the keys** of an input array. You can specify 128 | [Index Policies](#index-policies) if you interpret the array indices as data too. 129 | 130 | ### `BoolArrayFilter` 131 | 132 | The `BoolArrayFilter` is used to ensure that the input is a one-dimensional array of booleans. It will cast any 133 | non-empty value to `true` and empty values to `false`. 134 | 135 | ```php 136 | addFilter('options', new BoolArrayFilter()); 142 | 143 | $input = [ 144 | 'options' => [true, false, 1, 0, 'true', 'false', '', null] 145 | ]; 146 | 147 | try { 148 | $valid = $ic($input); 149 | /* 150 | $valid will be: 151 | [ 152 | 'options' => [true, false, true, false, true, false, false, false] 153 | ] 154 | */ 155 | } catch (\TypeError $ex) { 156 | // Handle error 157 | } 158 | ``` 159 | 160 | ### `FloatArrayFilter` 161 | 162 | The `FloatArrayFilter` ensures the input is a one-dimensional array of floats. 163 | 164 | **Example:** 165 | ```php 166 | addFilter('prices', new FloatArrayFilter()); 172 | 173 | $input = ['prices' => [9.99, 19.99, 0.99]]; 174 | 175 | try { 176 | $valid = $ic($input); 177 | } catch (\TypeError $ex) { 178 | // Handle error 179 | } 180 | ``` 181 | 182 | ### `IntArrayFilter` 183 | 184 | The `IntArrayFilter` is used to ensure that the input is a one-dimensional array of integers. It attempts to cast values 185 | to integers. 186 | 187 | * Numeric strings will be cast to integers. 188 | * Floats will be cast to integers (truncating the decimal part). 189 | * `null` or empty strings (`''`) will be replaced with the default value, which is `0`. 190 | * Non-numeric strings will cause a `TypeError`. 191 | 192 | ```php 193 | addFilter('numbers', new IntArrayFilter()); 199 | 200 | // Valid input 201 | $input = [ 202 | 'numbers' => [1, '2', 3.0, null, ''] 203 | ]; 204 | 205 | try { 206 | $valid = $ic($input); 207 | /* 208 | $valid will be: 209 | [ 210 | 'numbers' => [1, 2, 3, 0, 0] 211 | ] 212 | */ 213 | } catch (\TypeError $ex) { 214 | // Handle error 215 | } 216 | 217 | // Invalid input 218 | $invalidInput = [ 219 | 'numbers' => [1, 'foo', 3] 220 | ]; 221 | 222 | try { 223 | $ic($invalidInput); 224 | } catch (\TypeError $ex) { 225 | // This will throw a TypeError because 'foo' is not a valid integer. 226 | } 227 | ``` 228 | 229 | ### `StringArrayFilter` 230 | 231 | The `StringArrayFilter` ensures the input is a one-dimensional array of strings. 232 | 233 | **Example:** 234 | ```php 235 | addFilter('tags', new StringArrayFilter()); 241 | 242 | $input = ['tags' => ['php', 'security', 'ionizer']]; 243 | 244 | try { 245 | $valid = $ic($input); 246 | } catch (\TypeError $ex) { 247 | // Handle error 248 | } 249 | ``` 250 | 251 | ### Index Policies 252 | 253 | Ionizer allows for policies to be defined on the indices (a.k.a. keys) of an array. 254 | 255 | #### `AnyIndex` 256 | 257 | Allows any string or integer index to be used on an array. This is congruent to not specifying an Index Policy at all. 258 | 259 | ```php 260 | setIndexPolicy(new AnyIndex()); 266 | ``` 267 | 268 | #### `IntegersOnly` 269 | 270 | Allows any integer key. 271 | 272 | ```php 273 | setIndexPolicy(new IntegersOnly()); 279 | ``` 280 | 281 | #### `StringsOnly` 282 | 283 | Allows any string key. 284 | 285 | ```php 286 | setIndexPolicy(new StringsOnly()); 292 | ``` 293 | 294 | #### `KeyAllowList` 295 | 296 | Allows only a specific set of keys. 297 | 298 | ```php 299 | setIndexPolicy(new IndexAllowList('foo', 'bar', 'baz')); 305 | ``` 306 | 307 | #### Custom Index Policies 308 | 309 | To create your own index policy, create a class that implements 310 | [`IndexPolicyInterface`](../src/Contract/IndexPolicyInterface.php). 311 | 312 | ```php 313 | addFilter( 339 | 'domain', 340 | new AllowList('US-1', 'US-2', 'EU-1', 'EU-2') 341 | ); 342 | 343 | $input = ['domain' => 'US-1']; 344 | $invalid = ['domain' => 'CA-1']; 345 | 346 | try { 347 | $valid = $ic($input); // OK 348 | $ic($invalid); // Throws TypeError 349 | } catch (\TypeError $ex) { 350 | // Handle error 351 | } 352 | ``` 353 | 354 | ### `EmailAddressFilter` 355 | 356 | The `EmailAddressFilter` filter validates that the input is an email address for a valid domain name with an MX record. 357 | This means that there is only one `@` character in the string and what follows is a valid email address for receiving 358 | email. It doesn't guarantee that there is a valid inbox on the other end. 359 | 360 | ```php 361 | addFilter( 367 | 'email', 368 | new EmailAddressFilter() 369 | ); 370 | 371 | $input = ['email' => 'foo@example.com']; 372 | $invalid = ['email' => 'foo@invalid-domain-name-goes-here']; 373 | 374 | try { 375 | $valid = $ic($input); // OK 376 | $ic($invalid); // Throws TypeError 377 | } catch (\TypeError $ex) { 378 | // Handle error 379 | } 380 | ``` 381 | -------------------------------------------------------------------------------- /tests/ArrayFilterTest.php: -------------------------------------------------------------------------------- 1 | addFilter('test', new ArrayFilter()) 58 | ->addFilter('test.apple', new BoolFilter()) 59 | ->addFilter('test.boy', new FloatFilter()) 60 | ->addFilter('test.cat', new IntFilter()) 61 | ->addFilter('test.dog', new StringFilter()); 62 | 63 | if (!($filter instanceof GeneralFilterContainer)) { 64 | $this->fail('Type error'); 65 | } 66 | 67 | $before = [ 68 | 'test' => [ 69 | 'apple' => 1, 70 | 'boy' => '1.345', 71 | 'cat' => '25519', 72 | 'dog' => 3.14159265 73 | ] 74 | ]; 75 | $this->assertEquals( 76 | [ 77 | 'test' => [ 78 | 'apple' => true, 79 | 'boy' => 1.345, 80 | 'cat' => 25519, 81 | 'dog' => '3.14159265' 82 | ] 83 | ], 84 | $filter($before) 85 | ); 86 | 87 | $wrong = (new GenericFilterContainer()) 88 | ->addFilter('test', new ArrayFilter()) 89 | ->addFilter('test.apple', new BoolFilter()) 90 | ->addFilter('test.boy', new FloatFilter()) 91 | ->addFilter('test.cat', new IntFilter()) 92 | ->addFilter('test.dog', new StringFilter()); 93 | 94 | if (!($wrong instanceof GeneralFilterContainer)) { 95 | $this->fail('Type error'); 96 | } 97 | $this->assertEquals( 98 | [ 99 | 'test' => [ 100 | 'apple' => true, 101 | 'boy' => 1.345, 102 | 'cat' => 25519, 103 | 'dog' => '3.14159265' 104 | ], 105 | 'test.apple' => false, 106 | 'test.boy' => 0.0, 107 | 'test.cat' => 0, 108 | 'test.dog' => '' 109 | ], 110 | $wrong($before) 111 | ); 112 | 113 | $corrected = (new GenericFilterContainer()) 114 | ->addFilter('test', new ArrayFilter()) 115 | ->addFilter('test::apple', new BoolFilter()) 116 | ->addFilter('test::boy', new FloatFilter()) 117 | ->addFilter('test::cat', new IntFilter()) 118 | ->addFilter('test::dog', new StringFilter()); 119 | 120 | if (!($corrected instanceof GeneralFilterContainer)) { 121 | $this->fail('Type error'); 122 | } 123 | $this->assertEquals( 124 | [ 125 | 'test' => [ 126 | 'apple' => true, 127 | 'boy' => 1.345, 128 | 'cat' => 25519, 129 | 'dog' => '3.14159265' 130 | ] 131 | ], 132 | $corrected($before) 133 | ); 134 | } 135 | 136 | /** 137 | * @throws Error 138 | * @throws InvalidDataException 139 | */ 140 | public function testBoolArrayFilter(): void 141 | { 142 | $filter = (new GeneralFilterContainer()) 143 | ->addFilter('test', new BoolArrayFilter()); 144 | 145 | if (!($filter instanceof GeneralFilterContainer)) { 146 | $this->fail('Type error'); 147 | } 148 | 149 | $before = [ 150 | 'test' => [ 151 | '', 152 | null, 153 | 0, 154 | 1, 155 | 'true', 156 | 'false' 157 | ] 158 | ]; 159 | $after = $filter($before); 160 | 161 | $this->assertSame( 162 | [ 163 | 'test' => [ 164 | false, 165 | false, 166 | false, 167 | true, 168 | true, 169 | true 170 | ] 171 | ], 172 | $after 173 | ); 174 | 175 | try { 176 | $typeError = [ 177 | 'test' => [[]] 178 | ]; 179 | $filter($typeError); 180 | $this->fail('Expected a TypeError'); 181 | } catch (TypeError $ex) { 182 | } 183 | } 184 | 185 | /** 186 | * @throws Error 187 | * @throws InvalidDataException 188 | */ 189 | public function testFloatArrayFilter(): void 190 | { 191 | $filter = (new GeneralFilterContainer()) 192 | ->addFilter('test', new FloatArrayFilter()); 193 | 194 | if (!($filter instanceof GeneralFilterContainer)) { 195 | $this->fail('Type error'); 196 | } 197 | 198 | $before = [ 199 | 'test' => [ 200 | null, 201 | '', 202 | 0, 203 | 33 204 | ] 205 | ]; 206 | $after = $filter($before); 207 | 208 | $this->assertSame( 209 | [ 210 | 'test' => [ 211 | 0.0, 212 | 0.0, 213 | 0.0, 214 | 33.0 215 | ] 216 | ], 217 | $after 218 | ); 219 | 220 | try { 221 | $typeError = [ 222 | 'test' => [2, 3, []], 223 | 'test2' => [[1.5]] 224 | ]; 225 | $filter($typeError); 226 | $this->fail('Expected a TypeError'); 227 | } catch (TypeError $ex) { 228 | } 229 | } 230 | 231 | /** 232 | * @throws Error 233 | * @throws InvalidDataException 234 | */ 235 | public function testIntArrayFilter(): void 236 | { 237 | $filter = (new GeneralFilterContainer()) 238 | ->addFilter('test', new IntArrayFilter()) 239 | ->addFilter('test2', new IntArrayFilter()); 240 | 241 | if (!($filter instanceof GeneralFilterContainer)) { 242 | $this->fail('Type error'); 243 | } 244 | 245 | $before = [ 246 | 'test' => [ 247 | null, 248 | '', 249 | 0, 250 | 33 251 | ], 252 | 'test2' => ['1'] 253 | ]; 254 | $after = $filter($before); 255 | 256 | $this->assertSame( 257 | [ 258 | 'test' => [ 259 | 0, 260 | 0, 261 | 0, 262 | 33 263 | ], 264 | 'test2' => [ 265 | 1 266 | ] 267 | ], 268 | $after 269 | ); 270 | 271 | try { 272 | $typeError = [ 273 | 'test' => ['1', []], 274 | 'test2' => [[1]] 275 | ]; 276 | $filter($typeError); 277 | $this->fail('Expected a TypeError'); 278 | } catch (TypeError $ex) { 279 | } 280 | } 281 | 282 | /** 283 | * @throws Error 284 | * @throws Exception 285 | * @throws InvalidDataException 286 | */ 287 | public function testStrictArrayFilter(): void 288 | { 289 | try { 290 | (new GeneralFilterContainer()) 291 | ->addFilter('test', new StrictArrayFilter('float', 'string')); 292 | } catch (\RuntimeException $ex) { 293 | $this->assertSame( 294 | 'Cannot accept key types other than "int" or "string".', 295 | $ex->getMessage() 296 | ); 297 | } 298 | 299 | $filter = (new GeneralFilterContainer()) 300 | ->addFilter('test', new StrictArrayFilter('int', 'string')); 301 | 302 | if (!($filter instanceof GeneralFilterContainer)) { 303 | $this->fail('Type error'); 304 | } 305 | $filter([ 306 | 'test' => [ 307 | 'abc', 308 | 'def' 309 | ] 310 | ]); 311 | 312 | try { 313 | $filter([ 314 | 'test' => [ 315 | 1, 316 | 'abc', 317 | 'def' 318 | ] 319 | ]); 320 | $this->fail('Uncaught value mismatch'); 321 | } catch (TypeError $ex) { 322 | $this->assertSame( 323 | 'Expected an array. At least one element of was found (test[0] == 1).', 324 | $ex->getMessage() 325 | ); 326 | } 327 | $filter([ 328 | 'test' => [ 329 | 1 => 'abc', 330 | 2 => 'def' 331 | ] 332 | ]); 333 | try { 334 | $filter([ 335 | 'test' => [ 336 | 1 => 'abc', 337 | '1a' => 'def' 338 | ] 339 | ]); 340 | $this->fail('Uncaught value mismatch'); 341 | } catch (TypeError $ex) { 342 | $this->assertSame( 343 | 'Expected an array. At least one element of was found (test["1a"] == "def").', 344 | $ex->getMessage() 345 | ); 346 | } 347 | 348 | $filter->addFilter('second', new StrictArrayFilter('string', \stdClass::class)); 349 | $filter([ 350 | 'test' => ['abc', 'def'], 351 | 'second' => [ 352 | 'test' => (object)['test'] 353 | ] 354 | ]); 355 | $filter([ 356 | 'test' => ['abc', 'def'], 357 | 'second' => [ 358 | '1234a' => (object)['test'] 359 | ] 360 | ]); 361 | try { 362 | $filter([ 363 | 'test' => ['abc', 'def'], 364 | 'second' => [ 365 | 'test' => (object)['test'], 366 | 123 => (object)['test2'], 367 | ] 368 | ]); 369 | $this->fail('Invalid key accepted'); 370 | } catch (TypeError $ex) { 371 | $this->assertSame( 372 | 'Expected an array. At least one element of was found (second[123] == {"0":"test2"}).', 373 | $ex->getMessage() 374 | ); 375 | } 376 | try { 377 | $filter([ 378 | 'test' => ['abc', 'def'], 379 | 'second' => [ 380 | 'test' => (object)['test'], 381 | '123' => null, 382 | ] 383 | ]); 384 | $this->fail('Null accepted where it was not wanted'); 385 | } catch (TypeError $ex) { 386 | $this->assertSame( 387 | 'Expected an array. At least one element of was found (second[123] == null).', 388 | $ex->getMessage() 389 | ); 390 | } 391 | 392 | $cf = (new GeneralFilterContainer()); 393 | $cf->addFilter('test', new StrictArrayFilter('string', 'callable')); 394 | $cf([ 395 | 'test' => [ 396 | 'a' => function() { return 'foo'; }, 397 | 'b' => '\\strlen', 398 | 'c' => [StringFilter::class, 'nonEmpty'] 399 | ], 400 | ]); 401 | 402 | $fuzz = bin2hex(\random_bytes(33)); 403 | try { 404 | $cf([ 405 | 'test' => [ 406 | 'a' => function() { return 'foo'; }, 407 | 'b' => $fuzz 408 | ], 409 | ]); 410 | $this->fail('Invalid function name was declared'); 411 | } catch (TypeError $ex) { 412 | $this->assertSame( 413 | 'Expected an array. At least one element of was found (test["b"] == "' . $fuzz . '").', 414 | $ex->getMessage() 415 | ); 416 | } 417 | } 418 | 419 | /** 420 | * @throws Error 421 | * @throws InvalidDataException 422 | */ 423 | public function testStringArrayFilter(): void 424 | { 425 | $filter = (new GeneralFilterContainer()) 426 | ->addFilter('test', new StringArrayFilter()); 427 | 428 | if (!($filter instanceof GeneralFilterContainer)) { 429 | $this->fail('Type error'); 430 | } 431 | 432 | $before = [ 433 | 'test' => [ 434 | null, 435 | '', 436 | 0, 437 | 33 438 | ] 439 | ]; 440 | $after = $filter($before); 441 | 442 | $this->assertSame( 443 | [ 444 | 'test' => [ 445 | '', 446 | '', 447 | '0', 448 | '33' 449 | ] 450 | ], 451 | $after 452 | ); 453 | 454 | try { 455 | $typeError = [ 456 | 'test' => ['a', []] 457 | ]; 458 | $filter($typeError); 459 | $this->fail('Expected a TypeError'); 460 | } catch (TypeError $ex) { 461 | } 462 | } 463 | 464 | } --------------------------------------------------------------------------------