2026年5月20日
2026年5月20日
WordPressのSQLインジェクション対策|$wpdb->prepareの正しい使い方とプレースホルダー
WordPressでSQLインジェクションを防ぐ基本は、外部から来た値をSQL文に直接埋め込まず、$wpdb->prepare() のプレースホルダー(%s %d %f、WordPress 6.2以降は識別子用の %i)を通して渡すことです。LIKE検索では $wpdb->esc_like() を組み合わせ、ORDER BY のようにプレースホルダーが使えない箇所は許可リストで値を絞ります。この記事では、脆弱なコードがなぜ危険かを確認したうえで、prepare() の正しい書き方、つまずきやすいパターン、既存コードの点検方法までを説明します(2026年10月現在)。
脆弱なコードの典型例
SQLインジェクションは、ユーザーが送った文字列がSQL文の一部として解釈されてしまう脆弱性です。WordPressのテーマやプラグインでは、次のような書き方が原因になります。
<?php
// 危険: $_GET の値をそのままSQL文に埋め込んでいる
global $wpdb;
$status = $_GET['status'];
$rows = $wpdb->get_results(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = '$status'"
);
status に publish' OR '1'='1 のような文字列を送られると、WHERE句の条件が書き換わり、本来見えないはずの下書きや非公開の投稿まで取得されます。UNION SELECT を使えば、wp_users テーブルのパスワードハッシュなど別のテーブルの中身を引き出すことも可能です。
ここで使われている {$wpdb->posts} のようなテーブル名の埋め込み自体は、WordPressが用意した値なので問題ありません。危険なのは、$_GET・$_POST・$_COOKIE・$_REQUEST、REST APIのパラメータ、外部APIの応答など、サイトの外から来た値を埋め込むことです。データベースに一度保存された値でも、元が外部入力なら同じように扱います。
$wpdb->prepare()の基本
$wpdb->prepare() は、SQL文の中の値の位置にプレースホルダーを書き、実際の値を別の引数で渡す関数です。WordPressが値を型に合わせてエスケープし、文字列は引用符で囲んだうえで差し込みます。
<?php
global $wpdb;
$status = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : 'publish';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = %s AND post_author = %d",
$status,
get_current_user_id()
)
);
使えるプレースホルダーは次の4つです。
%s:文字列。エスケープされ、シングルクォートで囲まれる%d:整数。整数に変換される%f:浮動小数点数%i:テーブル名・カラム名などの識別子(WordPress 6.2以降)。バッククォートで囲まれる
書くときの注意点は3つあります。
1つ目は、**%s を自分で引用符で囲まない**ことです。'%s' と書くと、引用符の扱いがWordPressの処理と重なり、意図しない結果になる原因になります。プレースホルダーは裸のまま書きます。
2つ目は、値はすべて引数で渡すことです。prepare() を使っていても、SQL文の文字列に $status を直接書いてしまえば保護されません。「prepareを通しているから安全」ではなく、「値がプレースホルダー経由で入っているから安全」です。
3つ目は、**SQL文の中にリテラルの % を書く場合は %% にする**ことです。DATE_FORMAT(post_date, '%Y') のような書式文字列はプレースホルダーと誤認されるので、'%%Y' と書きます。
なお、プレースホルダーを1つも含まないSQL文を prepare() に渡すと、WordPressはデバッグ時に「正しくない使い方」の通知を出します。値を含まない固定のSQL文なら、prepare() は不要です。
テーブル名・カラム名には%iを使う
WordPress 6.2で追加された %i を使うと、独自テーブルの名前やカラム名を安全に差し込めます。
<?php
global $wpdb;
$table = $wpdb->prefix . 'my_logs';
$count = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM %i WHERE level = %s',
$table,
'error'
)
);
%i は識別子として正しくクォートしますが、「どのカラムを指定してよいか」までは判断しません。ユーザーにカラムを選ばせる場合は、次の節の許可リストと組み合わせます。プラグインが WordPress 6.2 未満もサポートする場合、%i はそのバージョンでは解釈されないので、テーブル名を {$wpdb->prefix} で組み立てる従来の書き方にとどめます。
LIKE検索ではesc_like()を先に通す
部分一致検索では、ユーザーの入力に含まれる % や _ がLIKEのワイルドカードとして働いてしまいます。$wpdb->esc_like() でこれらをエスケープしてから、前後に自分で % を付け、%s で渡します。
<?php
global $wpdb;
$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 = 'publish' LIMIT 20",
$like
)
);
esc_like() はLIKE用のワイルドカードを無効にするだけで、SQLインジェクションを防ぐ処理ではありません。**esc_like() → prepare() の順に両方通す**のが正しい使い方です。
IN句・ORDER BYなど、プレースホルダーが使いにくい箇所
IN ( ... ) に複数の値を渡したい場合は、値の数だけプレースホルダーを並べた文字列を作ります。
<?php
global $wpdb;
$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
)
);
}
$placeholders は %d, %d, %d という固定の文字列で、ユーザーの値は含まれないので、SQL文に埋め込んでも問題ありません。prepare() の第2引数には、このように配列をまとめて渡すこともできます。
並び順の ASC / DESC はプレースホルダーでは指定できません。こうした箇所は、受け付ける値をコード側で決めておき、それ以外は既定値に置き換えます。
<?php
global $wpdb;
$allowed_orderby = array( 'post_date', 'post_title', 'menu_order' );
$orderby = in_array( $_GET['orderby'] ?? '', $allowed_orderby, true ) ? $_GET['orderby'] : 'post_date';
$order = ( 'ASC' === strtoupper( $_GET['order'] ?? '' ) ) ? 'ASC' : 'DESC';
$sql = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' ORDER BY $orderby $order LIMIT 20";
この例で $orderby と $order がSQL文に入るのは、許可リストに一致した値か固定の既定値だけです。WordPressには ORDER BY 句の形式を検査する sanitize_sql_orderby() もありますが、許可リストのほうが意図がはっきりします。
INSERT・UPDATE・DELETEはヘルパー関数を使う
行の追加・更新・削除には、$wpdb->insert()・$wpdb->update()・$wpdb->delete() が用意されています。値と書式の配列を渡すと、内部でエスケープされます。
<?php
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'my_logs',
array(
'user_id' => get_current_user_id(),
'message' => sanitize_text_field( wp_unslash( $_POST['message'] ?? '' ) ),
),
array( '%d', '%s' )
);
さらに言えば、投稿・ユーザー・メタデータの取得や更新には、WP_Query、get_posts()、get_user_meta()、update_post_meta() などのAPIを使うのが第一選択です。これらは内部でエスケープ済みなので、自分でSQLを書く場面そのものが減ります。
prepareと組み合わせる他の対策
prepare() はSQLインジェクションを防ぎますが、「その操作をしてよい人か」は判断しません。データを変更する処理では、次の対策も合わせて入れます。
- 権限チェック:
current_user_can( 'edit_posts' )などで、操作できるユーザーかを確認する - nonceの検証:
check_admin_referer()やwp_verify_nonce()で、正規のフォームから送られたリクエストかを確認する - 入力の整形:
absint()、sanitize_text_field()、sanitize_key()などで、期待する形式に整える - DBユーザーの権限を絞る:WordPressが使うMySQLユーザーに、そのデータベース以外への権限を与えない
esc_sql() という関数もありますが、引用符を付けないため使い方を誤りやすく、通常は prepare() を使います。
既存コードを点検する方法
自作のテーマやプラグインに問題がないかは、まず $wpdb-> を含む行を洗い出し、SQL文の中に $ で始まる変数が直接入っていないかを見ていきます。
grep -rn --include='*.php' -E '\$wpdb->(query|get_results|get_row|get_var|get_col)\(' wp-content/themes/my-theme wp-content/plugins/my-plugin
数が多い場合は、PHP_CodeSniffer と WordPress Coding Standards を使うと、prepare() を通していないSQLや、変数を直接埋め込んだSQLを自動で指摘してくれます。公式の Plugin Check プラグインでも同様の観点の検査ができます。
脆弱性を見つけて修正する際は、修正前のファイルとデータベースのバックアップを取ってから作業してください。実際に攻撃を試す検証は、本番ではなくローカルやステージング環境で行います。
まとめ
- 外部から来た値はSQL文に直接埋め込まず、
$wpdb->prepare()のプレースホルダーで渡す - プレースホルダーは
%s%d%f、識別子は%i(WordPress 6.2以降)。%sは引用符で囲まない - LIKE検索は
esc_like()で処理してから%sで渡す。リテラルの%は%% - IN句はプレースホルダーを数だけ並べ、ORDER BYや並び順は許可リストで絞る
- 追加・更新・削除は
$wpdb->insert()などのヘルパーか、WordPressの標準APIを使う - 権限チェックとnonceの検証を組み合わせ、PHPCSなどで既存コードを点検する