PHP

PHP:文字列が整数かの検証にis_numeric, ctype_digit, filter_varはどれも妥当ではない

ユーザ入力の文字列が整数かどうかを検証するにあたって、is_numericctype_digitを用いるのはもちろん、filter_varも妥当とは言えず、CakePHP3のバリデーションライブラリにも問題点があることが明らかになった。なお、Symfonyはis_intis_numericctype_digitなどに移譲される実装になっており、Laravelはfilter_varを使用していたため同様の問題があることが分かった。

結論としては、正規表現で検証することを推奨したい。filter_varやCakePHP3のバリデーションライブラリを使うときも正規表現と組み合わせたほうが安全である。ちなみに、文字列に対してis_intintvalをかけることは何の検証にもならないので注意されたし。

参考までにis_numericfilter_varctype_digitそしてCakePHPのバリデーターと評価結果の関係性をまとめた表を示す。背景色が緑のところがtrueが返るパターンだ。加えて、参考までにintvalの振る舞いも示す。

is_numeric

is_numericは「数字かどうか」をチェックする関数であり、整数に限らなれない。少数もtrueになるだけでなく、指数表記やPHP_INT_MAXを超える数もtrue扱いである。したがって、「整数かどうか」をチェックする場面では適切ではない。

assert(is_numeric('0'));
assert(is_numeric('1'));
assert(is_numeric('1 ') === false); // 1と半角スペース
assert(is_numeric('a') === false);
assert(is_numeric('0.0'));
assert(is_numeric('.123'));
assert(is_numeric('123.'));
assert(is_numeric('-1'));
assert(is_numeric('-1.0'));
assert(is_numeric('+1'));
assert(is_numeric('+1.0'));
assert(is_numeric('042')); // 8進数
assert(is_numeric('08')); // 0で始まる10進数「8」
assert(is_numeric('1e2')); // 指数表現で100
assert(is_numeric('1.001e2')); // 指数表現100.1
assert(is_numeric('1e+2')); // 指数表現で100
assert(is_numeric('1e-2')); // 指数表現で0.01
assert(is_numeric('0xA') === false); // 16進数で10
assert(is_numeric('9223372036854775807')); // PHP_INT_MAX
assert(is_numeric('9223372036854775808')); // PHP_INT_MAX + 1
assert(is_numeric('-9223372036854775808')); // PHP_INT_MIN
assert(is_numeric('-9223372036854775809')); // PHP_INT_MIN - 1
assert(is_numeric('INF') === false);
assert(is_numeric('NAN') === false);

ctype_digit

ctype_digitは「数字かどうかを調べる」関数である。文字通り文字列が半角数字文字'0'~'9'だけで構成されているかをチェックするものである。負の数はfalseになる。したがって、「整数かどうか」をチェックする場面では適切ではない。

assert(ctype_digit('0'));
assert(ctype_digit('1'));
assert(ctype_digit('1 ') === false); // 1と半角スペース
assert(ctype_digit('a') === false);
assert(ctype_digit('0.0') === false);
assert(ctype_digit('.123') === false);
assert(ctype_digit('123.') === false);
assert(ctype_digit('-1') === false);
assert(ctype_digit('-1.0') === false);
assert(ctype_digit('+1') === false);
assert(ctype_digit('+1.0') === false);
assert(ctype_digit('042')); // 8進数
assert(ctype_digit('08')); // 0で始まる10進数「8」
assert(ctype_digit('1e2') === false); // 指数表現で100
assert(ctype_digit('1.001e2') === false); // 指数表現100.1
assert(ctype_digit('1e+2') === false); // 指数表現で100
assert(ctype_digit('1e-2') === false); // 指数表現で0.01
assert(ctype_digit('0xA') === false); // 16進数で10
assert(ctype_digit('9223372036854775807')); // PHP_INT_MAX
assert(ctype_digit('9223372036854775808')); // PHP_INT_MAX + 1
assert(ctype_digit('-9223372036854775808') === false); // PHP_INT_MIN
assert(ctype_digit('-9223372036854775809') === false); // PHP_INT_MIN - 1
assert(ctype_digit('INF') === false);
assert(ctype_digit('NAN') === false);

filter_var

filter_varは値の検証を目的に使われる関数であり、第一引数に検証する値、第二引数にFILTER_VALIDATE_INTを渡すことで、整数かどうかをチェックすることができる。しかし、これにも罠があり'1 '(1と半角スペース)についてはtrueを返してしまう。他の関数に比べると理想に最も近いが、前述の罠があるため、これ単体での使用はおすすめできない。

ちなみに、Laravelのバリデーターはfilter_varを使った実装になっているため、このケースと全く同じことが言える。LaravelのGitHubでは空白が入っている整数の場合はfalseを返すようにして変更ほしいというプルリクエストが送られたことがあったが、下位互換性が壊れるという理由で既存のバリデーターを変更することは却下されている。

$filter_var = function (string $value): bool {
    return filter_var($value, FILTER_VALIDATE_INT) !== false;
};

assert($filter_var('0'));
assert($filter_var('1'));
assert($filter_var('1 ')); // 1と半角スペース
assert($filter_var('a') === false);
assert($filter_var('0.0') === false);
assert($filter_var('.123') === false);
assert($filter_var('123.') === false);
assert($filter_var('-1'));
assert($filter_var('-1.0') === false);
assert($filter_var('+1'));
assert($filter_var('+1.0') === false);
assert($filter_var('042') === false); // 8進数
assert($filter_var('08') === false); // 0で始まる10進数「8」
assert($filter_var('1e2') === false); // 指数表現で100
assert($filter_var('1.001e2') === false); // 指数表現100.1
assert($filter_var('1e+2') === false); // 指数表現で100
assert($filter_var('1e-2') === false); // 指数表現で0.01
assert($filter_var('0xA') === false); // 16進数で10
assert($filter_var('9223372036854775807')); // PHP_INT_MAX
assert($filter_var('9223372036854775808') === false); // PHP_INT_MAX + 1
assert($filter_var('-9223372036854775808')); // PHP_INT_MIN
assert($filter_var('-9223372036854775809') === false); // PHP_INT_MIN - 1
assert($filter_var('INF') === false);
assert($filter_var('NAN') === false);

CakePHP3のバリデーター

CakePHP3のバリデーターで整数かどうかをチェックしてくれるのがCake\Validation\Validation::isInteger()メソッドだ。filter_varと近いふるまいをするが、8進数やPHP_MAX_INTなどは考慮されていないため、これ単体だけでバリデーションするのは避けたほうが良い。

/**
 * Check that the input value is an integer
 * This method will accept strings that contain only integer data
 * as well.
 * @param string $value The value to check
 * @return bool
 */
function cakephp($value) // ライブラリから抜粋した実装
{
    if (!is_scalar($value) || is_float($value)) {
        return false;
    }
    if (is_int($value)) {
        return true;
    }
    return (bool) preg_match('/^-?[0-9]+$/', $value);
}

assert(cakephp('0'));
assert(cakephp('1'));
assert(cakephp('1 ') === false); // 1と半角スペース
assert(cakephp('a') === false);
assert(cakephp('0.0') === false);
assert(cakephp('.123') === false);
assert(cakephp('123.') === false);
assert(cakephp('-1'));
assert(cakephp('-1.0') === false);
assert(cakephp('+1') === false);
assert(cakephp('+1.0') === false);
assert(cakephp('042')); // 8進数
assert(cakephp('08')); // 0で始まる10進数「8」
assert(cakephp('1e2') === false); // 指数表現で100
assert(cakephp('1.001e2') === false); // 指数表現100.1
assert(cakephp('1e+2') === false); // 指数表現で100
assert(cakephp('1e-2') === false); // 指数表現で0.01
assert(cakephp('0xA') === false); // 16進数で10
assert(cakephp('9223372036854775807')); // PHP_INT_MAX
assert(cakephp('9223372036854775808')); // PHP_INT_MAX + 1
assert(cakephp('-9223372036854775808')); // PHP_INT_MIN
assert(cakephp('-9223372036854775809')); // PHP_INT_MIN - 1
assert(cakephp('INF') === false);
assert(cakephp('NAN') === false);

intval

intvalは検証のための関数でないため、検証に使うのは不適切だが、参考までにそのふるまいを示す。

assert(intval('0') === 0);
assert(intval('1') === 1);
assert(intval('1 ') === 1); // 1と半角スペース
assert(intval('a') === 0);
assert(intval('0.0') === 0);
assert(intval('.123') === 0);
assert(intval('123.') === 123);
assert(intval('-1') === -1);
assert(intval('-1.0') === -1);
assert(intval('+1') === 1);
assert(intval('+1.0') === 1);
assert(intval('042') === 42); // 8進数
assert(intval('08') === 8); // 0で始まる10進数「8」
assert(intval('1e2') === 100); // 指数表現で100
assert(intval('1.001e2') === 100); // 指数表現100.1
assert(intval('1e+2') === 100); // 指数表現で100
assert(intval('1e-2') === 0); // 指数表現で0.01
assert(intval('0xA') === 0); // 16進数で10
assert(intval('9223372036854775807') === PHP_INT_MAX); // PHP_INT_MAX
assert(intval('9223372036854775808') === PHP_INT_MAX ); // PHP_INT_MAX + 1
assert(intval('-9223372036854775808') === PHP_INT_MIN); // PHP_INT_MIN
assert(intval('-9223372036854775809') === PHP_INT_MIN); // PHP_INT_MIN - 1
assert(intval('INF') === 0);
assert(intval('NAN') === 0);

所感