結論
・PHPに入ってくるときに入力値検証
・PHPから出ていくときにエスケープ
・サニタイズはない
サニタイズ
サニタイズって言うと怖い人たちがやってくるから言わないようにしましょう。
サニタイズは入力値検証もエスケープもなにもかもを含んだ広い意味になってしまったので、うっかり言ってることが食い違うと大変です。
入力値検証
入力値検証は、PHPの外からPHPに入ってくる値を検証することです。
外というと主に$_REQUEST
/$_GET
/$_POST
等のリクエストパラメータがイメージされますが、実際はそれ以外にも環境変数、コマンドライン引数、ファイルやデータベースからの読み込みなど、PHP以外からやってくる全てのものを指します。
入力値検証はセキュリティ対策ではない
もう入力値検証はセキュリティ対策としてあてにしないようにしようという記事がありますが、「もう」以前に、そもそも入力値検証は根本的にセキュリティ対策ではありません。
入力値検証は、『入力値が要件として正しい値か否かをチェックする』機能であって、そこにセキュリティ対策的な意味は全く含まれていません。
入力値検証の結果としてセキュリティ対策になることはありますが、あくまで偶然です。
セキュリティ対策であるエスケープとは完全に別物だという認識を持ちましょう。
不正なリクエスト
入力値検証と一口に言っても実際は2つの概念があります。
まずヌルバイトなど、絶対に入ってくるべきではない値がやってくる場合があります。
アプリケーションの要件などとは全く別の次元での話です。
これらはアプリの先頭でチェックを行い、入力があった場合は不正なリクエストとして、その時点で実行を停止すべきです。
不正な文字列を削除するような対処が多く見られますが、そもそも通常の仕様では有り得ないリクエストなので、処理を止めたところで誰からも文句は出ません。
あるいはWAFなどの下位レイヤでさっさと排除してしまうのもよいかもしれません。
バリデーション
電話番号として数値以外の値がやってくるなど、アプリケーションとして想定していない値をチェックするための入力値検証があります。
こちらはバリデーションと呼ばれ、入力値検証といえばだいたいこちらをイメージするでしょう。
リクエストパラメータ等をPHPで受け入れる部分でチェックを行います。
主にユーザの入力ミスなどが原因のため、バリデーションの失敗については処理を停止させず、エラーメッセージを出す対応が相応しいでしょう。
電話番号は数値(とハイフン)しか入ってこないので、電話番号の入力値検証を行った結果は数値(とハイフン)だけとなり、結果としてXSSやSQLインジェクションといった脆弱性の介在する余地がなくなります。
しかしこれはあくまで入力値検証の結果が偶々そうなっただけであり、決してセキュリティ対策ではありません。
自由入力欄の入力値検証を考えてみるとすぐに判るでしょう。
さて、入力値検証では値の変更をすべきではありません。
電話番号に英字が入っていたから削除する、全角数字が入っていたから半角にするなど、一見親切っぽい動作ですが、はっきりいって邪魔です。
実装コストもかかる上にバグも仕込みやすいといいことなしです。
ともあれ、住所欄に全角のみというバリデーションは滅ぶべきであると考える次第である。
エスケープ
エスケープとは、PHPからPHPの外に出ていく値を、相手側の形に合わせて整形してあげることです。
具体的にPHPの外とは、HTML、JavaScript、SQL、外部ファイル、そして標準出力など、まあ要するにPHP以外の全てです。
そして、エスケープを行う場所は『PHPからPHPの外に出ていくその場所』です。
ありがちな間違い
foreach($_REQUEST as $k=>$v){
$_REQUEST[$k] = htmlspecialchars($v);
}
絶対に書いてはいけません。
そもそも$_REQUEST
/$_GET
/$_POST
などは上書きするべきではないということがひとつ。
また、htmlspecialchars()はHTMLに出力するときのエスケープ方法であり、それ以外の出力先についてのエスケープ方法ではありません。
htmlspecialchars()した後の値をCSVなどに出力されたら文字化けします。
$name = htmlspecialchars($_REQUEST['name']);
/* 長い処理 */
echo $name;
あまりよくありません。
場所が離れている場合、その変数がエスケープされたか否か、HTMLエスケープしたか否か、わからなくなりがちです。
エスケープは必ず出力するそのときにその場所で行います。
HTML
PHPで一番よく使う出力先がここでしょう。
PHPでは<>'"&
などの文字を変数値としてそのまま使用することができますが、これをHTMLにそのまま出力すると、HTMLタグなどの特殊文字として解釈されてしまいます。
これをHTMLとして認識させず、入力した値と同じものとして見せるために、出力する値をHTMLエスケープする必要があります。
PHPにはそのための専用関数htmlspecialchars()が存在します。
ようこそ、<?=htmlspecialchars($_REQUEST['name'], ENT_QUOTES, 'UTF-8'); ?>さん。
このように、出力先にかかわらず、エスケープは必ず、出力を行うその時点で行います。
もっとも、最近はフレームワークを使うのが普通です。
フレームワークは大抵HTMLエスケープ機能を持っているため、htmlspecialchars()ではなくフレームワークに沿った出力方法を使いましょう。
また、フレームワークを使わないとしても、テンプレートエンジンとしてSmartyやTwigなどを使った方が利便性も安全性も高いので、HTML出力はテンプレートエンジンに任せた方がよいです。
従って、最近では直接htmlspecialchars()を書く機会はほとんどありません。
JavaScript
実はPHPには直接的なJavaScript文字列エスケープの機能がないため、JavaScriptへの出力は非常にややこしいです。
かわりにJSONにしてくれるjson_encode()があるので、JavaScriptへの出力はそちらを使うのがよいでしょう。
// PHP
$var = [1, 2, 3];
// JS
var array = <?=json_encode($var, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
JavaScriptによる処理そのものをPHP側で動的に組み立てるのはやめておきましょう。
PHPではJSONに値を書き出すだけにしておいて、実際のロジックはJavaScript側で行うのが安全です。
コマンド実行
exec、system、実行演算子といったコマンド実行関数は、escapeshellargでエスケープします。
$command = sprintf(
'ffmpeg -i %1$s -acodec copy sound.mp3',
escapeshellarg($_FILES['userfile']['tmp_name'])
);
echo shell_exec($command);
リクエストにhoge;cat /etc/.passwd;#
などといった文字を送られる被害を防ぎます。
なおescapeshellcmdは使用禁止です。
コマンド実行系の呼び出しはうっかり一歩間違うと即死するので、そもそも使わないという選択肢を検討しましょう。
画像処理であればImageMagickコマンドを直接打たずにImageMagickモジュールを使うといったふうに、内部・外部ライブラリを探して使うのがより安全です。
SQL
$pdo = new PDO('mysql:dbname=test;host=localhost;charset=utf8', $user, $pass, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT * FROM table WHERE name = :name');
$stmt->bindValue(':name', $_REQUEST['name'], PDO::PARAM_STR);
$stmt->execute();
$data = $stmt->fetchAll();
SQLの場合は、エスケープではなくプリペアドステートメントという機能を使います。
動的に入れ込まなければならない部分をいったん:name
といった別の固定値にしておいて、実際の値は別のメソッドで代入するという方法です。
これによって、直接的なエスケープで起こしやすいエスケープ忘れのうっかりミスを完全になくすことができます。
SQLに脆弱性があった場合、HTMLのエスケープ漏れとは比較にならない大きな損害が発生する可能性があるので、厳重に気をつけましょう。
なお、PDOのコンストラクタ第4引数に色々書いてあるやつは決まり文句なので、全て常に書いておくこと。
テキスト系
テキストファイル、メール本文、CSV、このあたりは特にエスケープを通す必要はありません。
送り先はプログラムではなくただのテキストなので、変な値が入っていても文字のまま扱われるからです。
HTMLメールとかは知らん。
file_put_contents('path/to/' . basename($_REQUEST['filename']), $_REQUEST['data']);
mail($to, $subject, $_REQUEST['message']);
fputcsv($fp, $_REQUEST['array']);
本文以外のファイル名、メールヘッダなどにはエスケープが必要です。
本文なども、実際には要件に従うようにバリデーションや整形を行うのが普通なので、実際に$_REQUESTを直接突っ込むようなことはあまりないでしょう。
おまけ
PHPにはかつてmagic_quotes_gpcという設定があり、上で絶対にやるなと言っていたありがちな間違いを、なんと言語レベルで行っていました。
これは当時(2000年くらい)のインターネットが今より牧歌的で、あまりセキュリティの研究も進んでいなかったからだと思われます。
しかし時代が進むにつれ徐々に危険性が指摘されるようになり、2009年のPHP5.3で非推奨となり、2012年のPHP5.4で削除されました。
今やif(get_magic_quotes_gpc())
とか書いてある記事は完全に過去のものなので忘れましょう。
まとめ
・サニタイズ > ( 入力値検証 + エスケープ )
・入力値検証とエスケープの区別ができていれば、そうそう脆弱性は入らない