1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLインジェクションを防ぐPHP・WordPressの書き方(PDO/wpdb::prepare・コピペOK)

1
Posted at

「自作の検索フォームや会員機能で、DBに渡す値の扱いが本当に安全か自信がない」——そんなWeb担当者・サイト運営者・制作会社の方向けに、SQLインジェクションを作り込まないためのPHP/WordPressの書き方をコピペできる形でまとめます。

読み終えると、①なぜ危険なのか、②PHPとWordPressでの正しい書き方、③プレースホルダに置き換えられない箇所の安全な扱い、までひととおり分かります。攻撃の再現手順ではなく、同じ穴を二度と作らないための実装に絞って解説します。

SQLインジェクションとは(根本原因は1つ)

SQLインジェクションは、ユーザーから受け取った値をSQL文の文字列にそのまま連結してしまうことで、値の一部がSQLの構文として解釈され、意図しない検索・改ざん・情報漏えいにつながる問題です。IPAの「安全なウェブサイトの作り方」でも最重要級の脆弱性として長年挙げられています。

危険なのは、次のように値を文字列連結しているコードです。

<?php
// ❌ 危険: $_GET の値をSQLにそのまま連結している
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = '" . $id . "'";
$stmt = $pdo->query($sql);

$id に想定外の文字(クォートやSQL構文)が入ると、WHERE 条件がまるごと書き換わってしまいます。

根本原因は「データ(値)とコード(SQL文)を文字列として混ぜている」ことだけです。逆に言えば、値をSQL文の外に出し、プレースホルダ経由で渡す(=プリペアドステートメント)ようにすれば、この問題は構造的に発生しなくなります。入力値のエスケープやサニタイズを自作するより、まずこちらを徹底します。

PHP(PDO)の安全な書き方 — コピペ雛形

PDOでは、値を ?(疑問符プレースホルダ)または :name(名前付きプレースホルダ)にしておき、実際の値は execute() / bindValue() で渡します。

<?php
// 接続時に例外モードと文字コードを指定(この2点も安全側の既定)
$pdo = new PDO(
    'mysql:host=localhost;dbname=app;charset=utf8mb4',
    $user,
    $pass,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false, // 本物のプリペアドを使う
    ]
);

// ✅ 名前付きプレースホルダ
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = :email AND status = :status');
$stmt->execute([
    ':email'  => $_POST['email'],
    ':status' => 'active',
]);
$rows = $stmt->fetchAll();

? プレースホルダなら、順番に値を渡します。

<?php
// ✅ 疑問符プレースホルダ(渡す順番 = ? の並び順)
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ? AND status = ?');
$stmt->execute([$_POST['email'], 'active']);
$rows = $stmt->fetchAll();

ポイントは 値のまわりにクォート '...' を書かないこと。プレースホルダはドライバ側が適切に扱うため、WHERE email = ':email' のようにクォートで囲むと逆に壊れます。また PDO::ATTR_EMULATE_PREPARES => false を明示し、擬似プリペアド(内部で文字列組み立て)ではなくサーバ側プリペアドを使うようにします。

PHP(mysqli)の場合

mysqliでも bind_param() でプレースホルダに値をバインドできます。型指定文字(s=文字列 / i=整数 / d=小数)を値の数だけ並べます。

<?php
$stmt = $mysqli->prepare('SELECT id, name FROM users WHERE email = ? AND age >= ?');
$stmt->bind_param('si', $_POST['email'], $minAge); // s:文字列, i:整数
$stmt->execute();
$result = $stmt->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);

プレースホルダに「できない」箇所の安全な扱い

プレースホルダに置き換えられるのは 値だけです。テーブル名・カラム名・ORDER BY の並び順・ASC/DESC などのSQLの構造部分はプレースホルダにできません。ここに外部入力を使いたい場合は、あらかじめ用意した許可リスト(ホワイトリスト)と完全一致したものだけを採用します。

<?php
// 並び替えカラムと方向を、決めておいた選択肢だけに絞る
$allowedSort = ['created_at', 'name', 'price']; // 許可カラム
$sort = in_array($_GET['sort'] ?? '', $allowedSort, true) ? $_GET['sort'] : 'created_at';

$dir = (strtolower($_GET['dir'] ?? '') === 'asc') ? 'ASC' : 'DESC';

// 構造部分は許可リスト由来の固定値、値はプレースホルダ
$sql = "SELECT id, name FROM products WHERE category = ? ORDER BY {$sort} {$dir} LIMIT ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$_GET['category'], 20]);

IN (...) 句も件数ぶんのプレースホルダを動的に生成して対応します。

<?php
// ✅ 配列の件数だけ ? を作って IN 句に展開する
$ids = array_values($_POST['ids']); // 例: [3, 7, 12]
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM items WHERE id IN ($placeholders)");
$stmt->execute($ids);

WordPress:$wpdb->prepare() の正しい使い方

WordPressで独自のSQLを書くとき(プラグインやテーマのカスタム機能など)は、必ず $wpdb->prepare() を通します。%s(文字列)・%d(整数)・%f(小数)のプレースホルダを使います。

<?php
global $wpdb;

// ✅ %s / %d をプレースホルダとして使う。%s のまわりにクォートは付けない
$sql  = $wpdb->prepare(
    "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = %s AND post_author = %d",
    'publish',
    $author_id
);
$rows = $wpdb->get_results( $sql );

よくある間違いを3つ挙げます。

  • prepare() を通さず変数を連結している:一番の事故原因。外部由来の値が1つでも入るなら必ず prepare() を使います。
  • '%s' とクォートで囲む:WHERE name = '%s' は二重に壊れます。%s はそのまま書きます(WordPressが適切に処理します)。
  • IN:IN (%s) に配列は渡せません。件数ぶんの %d/%s を組み立ててから渡します。
<?php
// ✅ WordPress で IN 句を安全に組む
$ids  = array_map( 'absint', (array) $_POST['ids'] ); // 整数化してさらに安全側に
$ph   = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$sql  = $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ($ph)", $ids );
$rows = $wpdb->get_col( $sql );

なお、WP_Queryget_posts() など標準APIで実現できる処理は、自分で生SQLを書かずにAPIを使うのが最も安全です。生SQLは「標準APIでは書けないときだけ」に限定しましょう。

仕上げの多層防御(3点)

プリペアドステートメントが本丸ですが、万一の被害を小さくする「もう一段」を添えておきます。

  • DBユーザーの権限を最小化:Webアプリが使うDBユーザーに DROP や他DBへのアクセス権を与えない。参照専用の処理は SELECT 権限だけのユーザーで動かす、という分離も有効です。
  • 本番でSQLエラーを画面に出さない:エラーメッセージからテーブル構造やクエリが漏れると、次の攻撃の手がかりになります(→ 関連記事)。
  • WAFで一律遮断を重ねる:アプリ側の実装ミスを取りこぼしても、共通的な攻撃パターンを手前で止める層を持っておくと安心です。ただしWAFは保険であって、プリペアドステートメントの代わりにはなりません

まとめ

  • SQLインジェクションの根本原因は「値をSQL文字列に連結する」1点。
  • 対策の本丸はプリペアドステートメント。PHPは PDO/mysqli、WordPressは $wpdb->prepare() を必ず通す。
  • テーブル名・ORDER BY など構造部分は許可リストで、IN 句は件数ぶんのプレースホルダで安全に。
  • 最小権限DBユーザー・本番エラー非表示・WAFで多層に守る。

関連記事

本記事のような入力の取り扱いや設定の抜けは、Webサイトを9つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?