filter_varで入力値を検証する – falseと0の罠、optionsの書き方

filter_var とは、値を指定したフィルタで検証または整形して返すPHPの関数です。検証に失敗すると false を返すため、0や空文字を扱うときは厳密比較が欠かせません。

フレームワークのバリデータを使っていると出番が減る関数ですが、素のPHPやライブラリの内部、バッチスクリプトあたりでは今でもよく見かけます。そして見かけるたびに、だいたい同じところで間違えています。今日はそのあたりを整理しておきます。

デフォルトでは何も検証してくれない

まず地味に事故るのがこれです。第2引数を省略すると FILTER_DEFAULT が使われますが、これは FILTER_UNSAFE_RAW の別名で、名前のとおり何もしません。

<?php
// これは「検証」ではない。値がそのまま返るだけ
$value = filter_var($input);

「filter」という名前から何かしら守ってくれる気がしてしまうのですが、フィルタを指定しなければ素通しです。レビューで見つけたら指摘しておきたいところですね。

0とfalseをどう見分けるか

検証系フィルタは、成功すると変換後の値を、失敗すると false を返します。つまり "0" を検証すると int(0) が返り、これは if では偽になります。

<?php
$qty = filter_var('0', FILTER_VALIDATE_INT);

if (!$qty) {
    // ここに入ってしまう。0は正しい入力なのに
}

if ($qty === false) {
    // こちらが正しい判定
}

もう一つの書き方が FILTER_NULL_ON_FAILURE です。失敗時に false ではなく null を返すようになるので、??is_null() で素直に書けます。すべての FILTER_VALIDATE_* フィルタで使えます。

<?php
$qty = filter_var('abc', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
// $qty は null

$qty = filter_var('0', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
// $qty は int(0)

数値を扱うコードでは、こちらのほうが判定を間違えにくいと思います。

optionsは入れ子にしないと効かない

範囲チェックを付けたいときの書き方です。第3引数には「フラグの整数」か「連想配列」のどちらかを渡せますが、オプションを使う場合は 'options' キーの中にもう一段入れる必要があります。

<?php
$options = [
    'options' => [
        'min_range' => 1,
        'max_range' => 100,
    ],
    'flags' => FILTER_NULL_ON_FAILURE,
];

var_dump(filter_var('50',  FILTER_VALIDATE_INT, $options)); // int(50)
var_dump(filter_var('200', FILTER_VALIDATE_INT, $options)); // NULL

この入れ子を忘れて ['min_range' => 1] と書くと、エラーも警告も出ないまま範囲チェックだけが無視されます。静かに壊れるタイプなので厄介です。default オプションを添えれば、失敗時に返す値も指定できます。

ついでに、意外と知られていない挙動を2つ。FILTER_VALIDATE_INTFILTER_VALIDATE_FLOAT は、文字列を検証する前に trim にかけます。つまり " 42 " は通ります。また、FILTER_VALIDATE_FLOATmin_range / max_range はPHP 7.4.0で追加されたもので、それ以前は使えません。

8進数・16進数を受け付けたいときはフラグを足します。

<?php
$options = [
    'options' => ['min_range' => 10],
    'flags'   => FILTER_FLAG_ALLOW_OCTAL,
];

var_dump(filter_var('0755', FILTER_VALIDATE_INT, $options)); // int(493)
var_dump(filter_var('011',  FILTER_VALIDATE_INT, $options)); // bool(false)

後者が false なのは、8進数の 011 が10進数の9で、min_range の10を下回るからです。フォームからの入力にうっかりこのフラグを付けると、先頭ゼロ付きの数字が8進数として解釈されてしまいます。しかも受け付けられるのは 0[0-7]+ の形だけなので、"08" のような値は逆に弾かれます。使いどころは選んだほうがいいですね。

FILTER_VALIDATE_BOOLは何をtrueと見なすのか

チェックボックスや設定値の文字列を真偽値に落とすときに便利なフィルタです。"1""true""on""yes"true"0""false""off""no"、空文字が false になります。比較は大文字小文字を区別しません。

<?php
var_dump(filter_var('YES', FILTER_VALIDATE_BOOL)); // bool(true)
var_dump(filter_var('off', FILTER_VALIDATE_BOOL)); // bool(false)

// どちらにも該当しない値は false になる
var_dump(filter_var('maybe', FILTER_VALIDATE_BOOL)); // bool(false)

// 「不正な入力」と区別したいならこう
var_dump(filter_var('maybe', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE)); // NULL

ここでも FILTER_NULL_ON_FAILURE が効いてきます。「明示的にoffと言われた」のか「そもそも解釈できない値だった」のかを区別できるのは、設定値を扱うコードではけっこう重要です。

なお定数名について。正式名の FILTER_VALIDATE_BOOL はPHP 8.0.0で導入されたもので、それ以前は別名の FILTER_VALIDATE_BOOLEAN だけがありました。BOOLEAN のほうも別名として残っているので、古いコードを見かけても書き換え必須ではありません。

filter_inputの戻り値はなぜややこしいのか

filter_input$_GET などを直接扱えて便利ですが、戻り値の意味が3つあります。

<?php
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);

// 成功    -> int
// 検証失敗 -> false
// 変数が未定義 -> null

つまり「pageパラメータが来ていない」と「pageに文字列が入っていた」を区別できます。ここで FILTER_NULL_ON_FAILURE を付けると、この2つが入れ替わって、未定義が false、検証失敗が null になります。直感に反する仕様なので、付けるなら意識して付けたほうがいいです。

もう一つ、これはハマると原因が分かりにくいのですが、filter_input が読むのはSAPIが最初に渡した「生の」スーパーグローバルの内容で、コード中で $_GET['page'] = 1; のように書き換えた結果は反映されません。ミドルウェアやテストでスーパーグローバルを差し替える設計だと、ここで確実に食い違います。書き換え後の値を見たいなら filter_var($_GET['page'] ?? null, ...) を使うことになります。

URLとメールの検証はどこまで信じていいか

FILTER_VALIDATE_URLFILTER_VALIDATE_EMAIL は便利な反面、期待しすぎると危ないフィルタです。

URLのほうは構文としてRFC 2396に沿っているかを見るだけで、スキームの種類は問いません。javascript: のようなものも構文上は妥当なので、リンク先として出力するなら自分でスキームを絞る必要があります。

<?php
$url = filter_var($input, FILTER_VALIDATE_URL);

if ($url === false) {
    throw new InvalidArgumentException('URLの形式が不正です');
}

$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
    throw new InvalidArgumentException('http(s)のURLだけ受け付けます');
}

もう一点、このフィルタはASCIIのURLしか扱えません。国際化ドメイン名(IDN)は常に弾かれます。日本語ドメインを受け付ける要件があるなら、idn_to_ascii() で変換してから通すことになります。

メールのほうはRFC 822のaddr-spec構文に対する検証で、コメントや折りたたみ空白、ドットを含まないドメイン名は受け付けません。そして当然ですが、構文が正しいことと、そのアドレスが実在して届くことは別の話です。公式マニュアルにも「本当に有効かを確かめる唯一の方法は実際にメールを送ることだ」と書かれています。確認メールを送る設計から逃げられるわけではない、ということですね。

PHP 8.5からは例外を投げられる

ここまで見てきた「戻り値で失敗を表す」設計のつらさは、PHP 8.5.0で追加された FILTER_THROW_ON_FAILURE でかなり楽になります。

<?php
use Filter\FilterFailedException;

try {
    $qty = filter_var($input, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1],
        'flags'   => FILTER_THROW_ON_FAILURE,
    ]);
} catch (FilterFailedException $e) {
    // 検証失敗をここで受ける
}

投げられるのは Filter\FilterFailedException で、これは Filter\FilterException を継承しています。0false の区別に悩まなくてよくなるのが一番の利点です。

注意点として、FILTER_THROW_ON_FAILUREFILTER_NULL_ON_FAILURE は同時に指定できません。値が正しい場合でも ValueError になります。移行するときは古いフラグを消し忘れないように、というくらいの話ですが、消し忘れると全件エラーになるので影響は大きいです。

ついでに、サニタイズ側の話

検証ではなく整形をする FILTER_SANITIZE_* のうち、FILTER_SANITIZE_STRING はPHP 8.1.0で非推奨になりました。代替として案内されているのは htmlspecialchars() です。

そもそもこのフィルタは「タグを剥がしつつクォートをHTMLエンコードする」という中途半端な動きで、剥がし方も strip_tags() と同じではありませんでした。出力時にエスケープする、という原則に戻るのが正解だと思います。

まとめ

filter_var は、フィルタを指定しなければ何もしないこと、失敗を false で返すこと、オプションが入れ子であることの3つを押さえておけば、だいたいの事故は避けられます。どれもエラーを出さずに静かに素通りするタイプの間違いなので、書いた本人が気づきにくいのが厄介なところですね。

PHP 8.5が使える環境なら FILTER_THROW_ON_FAILURE に寄せていくのが素直だと思います。戻り値の三値判定から解放されるだけでも、コードはかなり読みやすくなります。

よくある質問

Q. filter_varとフレームワークのバリデータはどちらを使うべきですか?
A. アプリケーションの入力検証はフレームワーク側に寄せたほうが、エラーメッセージや多言語対応まで含めて一貫します。filter_var が向くのは、ライブラリ内部や小さなスクリプトなど、依存を増やしたくない場所です。

Q. FILTER_VALIDATE_INTは ” 42 ” のような空白付きの文字列を通しますか?
A. 通ります。FILTER_VALIDATE_INTFILTER_VALIDATE_FLOAT は、文字列を検証する前に trim をかける仕様です。空白を許したくない場合は、事前に自分で弾く必要があります。

Q. FILTER_VALIDATE_EMAILを通れば送信して大丈夫ですか?
A. 構文が妥当というだけで、実在するかどうかは分かりません。公式マニュアルも、確実な確認方法は実際にメールを送ることだけだとしています。確認メールによる検証は省略できないと考えたほうがいいです。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です