2026年5月30日
2026年5月30日
SQLインジェクション対策の実践方法|WordPressの$wpdb->prepareで安全にクエリを書く
WordPress で SQL インジェクションを防ぐ基本は、利用者から届いた値を SQL 文字列へ直接つなげず、$wpdb->prepare() のプレースホルダーを通して渡すことです。この記事では、危険なコードと安全なコードを並べながら、数値・文字列・LIKE 検索・IN 句・ORDER BY といった場面ごとの書き方と、既存コードの点検方法までを順に説明します。自作プラグインやテーマの functions.php に独自クエリがある方が、今日から書き方を直せる内容です。
SQLインジェクションが起きる仕組み
SQL インジェクションは、入力値が「データ」ではなく「SQL 文の一部」として解釈されてしまうことで起きます。たとえば URL の ?id= をそのまま WHERE 句に入れていると、攻撃者は 1 OR 1=1 のような値を送り、条件を書き換えられます。結果として、他人のデータの閲覧、ログイン判定の回避、データの改ざんや削除につながります。
WordPress 本体の WP_Query、get_posts()、get_post_meta() などの API は内部でエスケープを行うため、これらを使っている限り直接の危険は小さくなります。問題になりやすいのは、$wpdb を使って自分で SQL を書いている箇所です。独自テーブルを持つプラグインや、集計のために生 SQL を書いたテーマで起こりがちです。
危険なコードと安全なコード
まず、典型的な危険な書き方です。
// 危険: 入力値を SQL に直接つないでいる
global $wpdb;
$id = $_GET['id'];
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}orders WHERE id = $id" );
これを $wpdb->prepare() で書き直します。
// 安全: プレースホルダーで値を渡す
global $wpdb;
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE id = %d",
$id
)
);
prepare() に渡した値は、プレースホルダーの種類に応じて整形・エスケープされてから SQL に入ります。absint() による型の整理は入口での正規化として役立ちますが、それだけに頼らず、クエリ側でも prepare() を通すのが基本です。入口と出口の二重の備えにしておくと、後から誰かがコードを変えたときにも崩れにくくなります。
なお、{$wpdb->prefix} のようにコード側で決まっている値は埋め込んでかまいません。避けるべきなのは、リクエストやデータベースから来た「外部の値」を直接つなぐことです。
プレースホルダーの種類と使い分け
$wpdb->prepare() で使えるプレースホルダーは次のとおりです。
%d:整数%f:浮動小数点数%s:文字列(自動で引用符が付きます)%i:テーブル名・カラム名などの識別子(WordPress 6.2 で追加)
つまずきやすいのは %s の扱いです。prepare() が引用符を付けるため、'%s' のように自分で引用符を書く必要はありません。また、値が一つもないのに prepare() を呼ぶと、開発時に注意メッセージが出ます。プレースホルダーのない固定の SQL は、prepare() を通さずにそのまま実行して問題ありません。
複数の値を渡すときは、引数を並べるか配列で渡します。
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE user_id = %d AND status = %s",
get_current_user_id(),
'completed'
);
%i は 6.2 より前の WordPress では使えません。配布するプラグインで古いバージョンもサポートする場合は、後述のホワイトリスト方式で識別子を固定してください。どの WordPress バージョンまでをサポート対象にするかは、プラグインごとに見直しておくと安心です(2026年10月現在)。
LIKE 検索は esc_like と組み合わせる
部分一致検索では、% と _ が LIKE のワイルドカードとして働きます。利用者の入力に含まれる % をそのまま渡すと、意図しない広い一致になります。SQL 文の破壊は prepare() で防げますが、ワイルドカードの意味を消すには $wpdb->esc_like() を先にかける必要があります。
$keyword = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
$like = '%' . $wpdb->esc_like( $keyword ) . '%';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s AND post_status = %s",
$like,
'publish'
)
);
順番が大切です。esc_like() で入力内のワイルドカードを無効にし、その後に自分で前後の % を付け、最後に prepare() の %s で渡します。esc_like() だけでは SQL インジェクション対策にならない点にも注意してください。
IN 句は件数分のプレースホルダーを作る
ID の配列で絞り込む場合、implode() で値を直接つなぐのは危険です。値の数だけ %d を並べて渡します。
$ids = isset( $_POST['ids'] ) ? array_map( 'absint', (array) $_POST['ids'] ) : array();
$ids = array_filter( $ids );
if ( $ids ) {
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ( $placeholders )",
$ids
)
);
}
ここで SQL に埋め込んでいる $placeholders は %d, %d, %d という固定の文字列だけなので安全です。空配列のまま実行すると IN () という不正な SQL になるため、件数の確認を先に入れています。
ORDER BY と並び順はホワイトリストで固定する
並び替えのカラム名や ASC / DESC は値ではなく SQL の構文なので、%s では渡せません。%s を使うと引用符で囲まれ、文字列として比較されるだけで並び替えになりません。許可する候補を配列で決め、その中から選ぶ形にします。
// URL で受け取る名前 => 実際のカラム名
$allowed_orderby = array(
'date' => 'post_date',
'title' => 'post_title',
'id' => 'ID',
);
$key = isset( $_GET['orderby'] ) ? sanitize_key( $_GET['orderby'] ) : 'date';
$orderby = isset( $allowed_orderby[ $key ] ) ? $allowed_orderby[ $key ] : 'post_date';
$order = ( isset( $_GET['order'] ) && 'asc' === sanitize_key( $_GET['order'] ) ) ? 'ASC' : 'DESC';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = %s ORDER BY $orderby $order LIMIT %d",
'publish',
20
)
);
URL で受け取るのは date や title といった短い名前だけにし、実際のカラム名はコード側の対応表から引いています。sanitize_key() は英字を小文字にそろえ、英数字・ハイフン・アンダースコア以外を取り除くため、大文字を含む ID のようなカラム名を直接比べると一致しなくなります。対応表にしておけば、この食い違いも起きません。SQL に入るのは対応表にある値だけです。WordPress 6.2 以降なら %i でカラム名を渡す方法もありますが、%i はどのカラム名でも受け付けてしまいます。並び替えに使ってよいカラムを限定する意味でも、ホワイトリストを併用するのが安心です。
insert・update・delete はフォーマット指定つきで
書き込み処理には $wpdb->insert()、$wpdb->update()、$wpdb->delete() が使えます。これらは内部で値をエスケープするため、自分で SQL を組み立てるより安全です。第3引数以降でフォーマットを指定しておくと、型の取り違えも防げます。
$wpdb->update(
"{$wpdb->prefix}orders",
array( 'status' => 'shipped', 'updated_at' => current_time( 'mysql' ) ), // 更新する値
array( 'id' => $order_id ), // WHERE 条件
array( '%s', '%s' ), // 値のフォーマット
array( '%d' ) // WHERE のフォーマット
);
ただし、書き込みの前には SQL とは別の守りも要ります。誰が実行してよい処理なのかを current_user_can() で確認し、フォームや Ajax からのリクエストなら check_admin_referer() や check_ajax_referer() で nonce を検証してください。SQL インジェクションを防いでも、権限のない利用者が他人の注文を書き換えられるなら別の脆弱性が残ります。
esc_sql や sanitize 関数だけに頼らない
esc_sql() は引用符などをエスケープしますが、引用符を付けてはくれません。数値を期待する箇所に esc_sql() を通した値を引用符なしで埋め込むと、1 OR 1=1 のような値はそのまま通ってしまいます。基本は prepare() を使い、esc_sql() はやむを得ない場面に限ってください。
同様に、sanitize_text_field() は HTML タグや改行を取り除く関数で、SQL 用のエスケープではありません。入口の「整形」と、クエリ時の「エスケープ」は役割が違うと覚えておくと混乱しません。
既存コードを点検する方法
手元のテーマやプラグインに危険な書き方が残っていないかは、次の手順で探せます。
$wpdb->query(、get_results(、get_var(、get_row(、get_col(を検索し、SQL 文字列の中に$_GETや$_POST、関数の引数が直接入っていないかを見る- ダブルクォートの SQL 文字列の中に変数が埋め込まれている箇所を、一つずつ「その値はどこから来たか」でたどる
- PHP_CodeSniffer と WordPress Coding Standards を導入し、データベース関連のルールで警告を出す。
prepare()を通していないクエリや、変数を埋め込んだクエリが指摘されます
点検や修正は、本番ではなくステージング環境で行い、作業前にファイルとデータベースのバックアップを取ってください。修正後は検索・並び替え・保存など、該当機能が今までどおり動くかを確認します。
あわせて、データベースユーザーの権限を必要な範囲に絞ること、WAF を導入して既知の攻撃パターンを入口で止めることも有効です。ただし WordPress 本体の更新ではテーブル変更の権限が必要になる場合があるため、権限を絞る際は更新手順への影響を確かめてから行います。WAF はあくまで追加の層で、コード側の対策の代わりにはなりません。
まとめ
- 外部から来た値は SQL に直接つながず、
$wpdb->prepare()のプレースホルダー(%d・%f・%s・%i)で渡す %sには引用符を付けない。%iは WordPress 6.2 以降で使える- LIKE 検索は
esc_like()→%付与 →prepare()の順にする - IN 句は件数分のプレースホルダーを作り、ORDER BY や並び順はホワイトリストで固定する
- 書き込みは
insert()/update()/delete()をフォーマット指定つきで使い、権限チェックと nonce 検証を併せて行う - 点検はステージングで、バックアップを取ってから。WAF は補助の層として考える