2026年8月24日
2026年8月24日
WordPressのSQLインジェクション対策:$wpdb->prepareの正しい使い方
WordPressで独自のSQLを書くときのSQLインジェクション対策は、利用者からの値を文字列としてSQLに埋め込まず、$wpdb->prepare() のプレースホルダー(%d %s %f %i)を通して渡すことが基本です。この記事では、危険な書き方と安全な書き方を並べながら、LIKE検索、IN句、並び替え(ORDER BY)といったつまずきやすい場面の書き方と、書いたコードの確認方法を説明します。
SQLインジェクションが起きる書き方
SQLインジェクションは、利用者が送った値がSQL文の一部として解釈されてしまうことで起きます。典型的なのは、$_GET や $_POST の値をそのまま文字列に埋め込むコードです。
// 危険な例:利用者の値をそのままSQLに埋め込んでいる
global $wpdb;
$id = $_GET['id'];
$row = $wpdb->get_row( "SELECT * FROM {$wpdb->posts} WHERE ID = $id" );
id に数字ではなく 0 OR 1=1 のような文字列が送られると、条件が書き換わり、意図しない行が返ります。値の種類によっては、他のテーブルの内容を読み出されたり、データを書き換えられたりするおそれがあります。
「入力値をサニタイズしているから大丈夫」と考えがちですが、sanitize_text_field() はHTMLタグや改行を取り除く関数で、SQLのための処理ではありません。サニタイズは入力値を想定どおりの形に整えるために行い、SQLへの安全な受け渡しは prepare() に任せる、と役割を分けて考えます。
$wpdb->prepareの基本
$wpdb->prepare() は、SQL文の中にプレースホルダーを書き、値を別の引数として渡す関数です。値は種類に応じてエスケープや型変換を受けてからSQLに組み込まれます。
global $wpdb;
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID = %d AND post_status = %s",
$id,
'publish'
)
);
使えるプレースホルダーは次のとおりです。
%d:整数%f:浮動小数点数%s:文字列(引用符はprepare()が付けるので、自分で'%s'と囲まない)%i:テーブル名や列名などの識別子。WordPress 6.2で追加され、バッククォートで囲んで出力されます。現行のWordPress(2026年10月現在)で利用できます
SQLの中で % という文字そのものを使いたいときは %% と書きます。また、プレースホルダーを1つも含まないSQLを prepare() に渡すと、開発者向けの警告(_doing_it_wrong の通知)が出ます。値を受け取らない固定のSQLなら、prepare() を通さずにそのまま実行して問題ありません。
なお、$wpdb->prepare() はデータベースサーバー側の「プリペアドステートメント」を使う仕組みではなく、WordPressが値をエスケープしてSQL文字列を組み立てる仕組みです。名前から想像される動作とは少し異なりますが、プレースホルダーを正しく使っていれば、値がSQL文の構造を変えることは防げます。
つまずきやすい場面の書き方
LIKE検索では esc_like を先に通す
LIKE検索では、利用者の入力に含まれる % や _ がワイルドカードとして働いてしまいます。$wpdb->esc_like() でそれらを無効にしてから、自分でワイルドカードを付け、その全体を %s で渡します。
global $wpdb;
$term = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
$like = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s AND post_status = %s LIMIT %d",
$like,
'publish',
20
)
);
esc_like() はLIKE用のワイルドカードを処理するだけで、SQLインジェクション対策そのものではありません。prepare() と組み合わせて使います。
IN句は個数分のプレースホルダーを作る
複数のIDで絞り込むときに、implode() でつないだ文字列をそのまま埋め込むのは危険です。値を整数に変換したうえで、個数分の %d を組み立てます。prepare() には値の配列をまとめて渡せます。
global $wpdb;
$raw_ids = isset( $_GET['ids'] ) ? (array) wp_unslash( $_GET['ids'] ) : array();
$ids = array_filter( array_map( 'absint', $raw_ids ) );
if ( $ids ) {
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ( $placeholders )",
$ids
)
);
}
ここでSQLに直接埋め込んでいる $placeholders は、コードが作った %d の並びだけなので、利用者の値は含まれていません。空配列のまま IN () を実行するとSQLエラーになるため、件数の確認も入れています。
ORDER BYは許可リストで受ける
並び替えの列名や ASC / DESC は値ではなくSQLの構造の一部なので、%s では渡せません。列名は許可リストで照合し、WordPress 6.2以降なら %i で渡します。並び順は2択に限定します。
global $wpdb;
$allowed_orderby = array( 'post_date', 'post_title', 'ID' );
$orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
$orderby = in_array( $orderby, array_map( 'strtolower', $allowed_orderby ), true ) ? $orderby : 'post_date';
if ( 'id' === $orderby ) {
$orderby = 'ID';
}
$order = ( isset( $_GET['order'] ) && 'asc' === strtolower( wp_unslash( $_GET['order'] ) ) ) ? 'ASC' : 'DESC';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = %s ORDER BY %i $order LIMIT %d",
'publish',
$orderby,
20
)
);
$order は ASC か DESC のどちらかしか入らないように作っているため、直接埋め込んでいます。WordPress 6.2より前の環境に配布するプラグインでは %i が使えないため、許可リストで照合した値だけを埋め込む形にします。
追加・更新・削除は専用メソッドを使う
INSERT・UPDATE・DELETEは、$wpdb->insert()、$wpdb->update()、$wpdb->delete() を使うと、値のエスケープを内部で行ってくれます。第3引数(update() では第4・第5引数)で型を指定しておくと、意図しない型の値が入るのを防げます。
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'myprefix_log',
array(
'user_id' => get_current_user_id(),
'message' => sanitize_text_field( wp_unslash( $_POST['message'] ?? '' ) ),
),
array( '%d', '%s' )
);
独自SQLの前に確認したいこと
WordPressの関数で済まないか
投稿の取得なら WP_Query や get_posts()、メタデータなら get_post_meta()、ユーザーなら get_users() など、WordPressの関数で実現できる処理は多くあります。これらは内部で安全にSQLを組み立てるため、独自SQLを書くよりも安全で、キャッシュとの連携も効きます。独自SQLは、関数では表現できない集計や独自テーブルの操作に絞るのがおすすめです。
権限とnonceの確認
SQLインジェクションを防いでも、誰でもデータを書き換えられる処理になっていては意味がありません。データを変更する処理では、current_user_can() で権限を、check_admin_referer() や wp_verify_nonce() でリクエストの正当性を確認します。取得した値を画面に出すときは esc_html() などでエスケープします。
書いたコードの確認方法
- 目視の確認:
$wpdb->query、get_results、get_row、get_var、get_colを検索し、SQL文字列の中に$_GETなどに由来する変数が直接入っていないかを確認します。 - 静的解析:PHP_CodeSniffer に WordPress Coding Standards を組み合わせると、
prepare()を通していないSQLを警告として検出できます(WordPress.DB.PreparedSQL系のルール)。導入方法はプロジェクトの公式ドキュメントを参照してください。 - 動作確認:ステージング環境で、数字を受け取る項目に文字列や記号を入れたときにエラーや意図しない結果にならないかを確認します。脆弱性診断ツールを使う場合は、自分が管理するサイト・環境に限って実施し、事前にバックアップを取っておきます。
まとめ
- 利用者の値はSQL文字列に埋め込まず、
$wpdb->prepare()のプレースホルダーで渡します。 %d%s%fに加え、WordPress 6.2以降は識別子用の%iが使えます。%sを自分で引用符で囲まないようにします。- LIKE検索は
esc_like()の後に%s、IN句は個数分のプレースホルダー、ORDER BYは許可リストで受けます。 - 追加・更新・削除は
$wpdb->insert()などの専用メソッドを使い、型を指定します。 - 可能ならWordPressの関数を使い、権限チェック・nonce・出力時のエスケープも合わせて行います。