プリペアドステートメントでSQLインジェクションを防ぐ
プリペアドステートメントは、SQLの構造と値を分離して実行する仕組みです。値がSQLの一部として解釈されなくなるため、SQLインジェクションを確実に防げます。
文字列連結でSQLを組むのをやめる
SQLインジェクションの根っこは、値をSQL文の文字列に直接埋め込んでしまうことにあります。エスケープ関数で頑張る方法もありますが、抜けが出やすく、そもそも発想として脆いですね。プリペアドステートメントを使えば、SQLの構造と値を分離できるので、値がどんな文字列でもSQLの一部として解釈されなくなります。これが一番確実な対策だと思います。
// 危険な例。$email に ' OR '1'='1 が入ると壊れる
$sql = "SELECT * FROM users WHERE email = '$email'";
// これはやってはいけない
PDOでプレースホルダを使う
PDO なら名前付きプレースホルダが使えて読みやすいです。値は execute に渡すか、bindValue で結び付けます。ポイントは、プレースホルダに入れられるのは「値」であって、テーブル名やカラム名は入れられないという点ですね。動的にカラム名を組みたい場合は、許可リストで検証してから連結するしかありません。
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // 本物のプリペアドにする
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
接続時に EMULATE_PREPARES を false にしておくと、エミュレーションではなくDB側のプリペアドを使うようになります。型の扱いが素直になるので、私はこれを既定にしています。
bindValueで型も指定する
LIMIT 句の値などは、エミュレーションを切ると文字列として渡されて構文エラーになることがあります。そういうときは bindValue で PDO::PARAM_INT を明示すると安定しますね。IN 句で複数値を渡したいときは、要素数ぶんのプレースホルダを動的に組み立てるのが定石です。
$stmt = $pdo->prepare('SELECT * FROM posts WHERE user_id = :uid LIMIT :lim');
$stmt->bindValue(':uid', $userId, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
// IN句はプレースホルダを数だけ作る
$in = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM tags WHERE id IN ($in)");
$stmt->execute($ids);
まとめ
SQLインジェクション対策は、エスケープで頑張るのではなく、プリペアドステートメントで構造と値を分離するのが本筋だと思います。値はプレースホルダ、識別子は許可リスト、エミュレーションは切って型を明示。この習慣が身につくと、そもそも危険なSQLを書かなくなるのが一番の効果だという気がします。
よくある質問
Q. エスケープ関数ではだめですか。
A. 抜けが出やすく発想として脆いです。プリペアドステートメントで構造と値を分離するのが確実です。
Q. テーブル名やカラム名もプレースホルダで渡せますか。
A. 渡せません。入れられるのは値だけです。識別子を動的にするなら許可リストで検証してから連結します。
Q. LIMIT句の値で構文エラーになります。
A. エミュレーションを切ると文字列として渡されるためです。bindValueで PDO::PARAM_INT を明示すると安定します。