2026年8月13日
2026年8月13日
SQLインジェクション対策の実践方法(WordPressのコードレビューで直すべき書き方)
WordPress でのSQLインジェクション対策の基本は、ユーザー入力を SQL 文字列に直接つなげず、$wpdb->prepare() のプレースホルダー経由で値を渡すことです。そのうえで、LIKE 検索・IN 句・ORDER BY のようにプレースホルダーだけでは扱いにくい箇所を正しく書き分ければ、自作プラグインやテーマの危険な書き方の大半は直せます。この記事では、悪い例と良い例を並べながら、コードレビューで見るべきポイントと、データベース側で被害を小さくする設定までを説明します。
SQLインジェクションが起きる仕組み
SQLインジェクションは、入力された値が「データ」ではなく「SQL の命令の一部」として解釈されてしまう問題です。たとえば次のコードは、URL の id をそのまま SQL に埋め込んでいます。
// 悪い例:入力をそのまま連結している
$id = $_GET['id'];
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE ID = $id" );
id に数値ではなく SQL の断片が入ると、条件を書き換えられて本来見えないデータを取得されるおそれがあります。WordPress のデータベースにはユーザー情報やパスワードのハッシュ、各種設定が入っているため、影響は小さくありません。
ここで大切なのは、sanitize_text_field() や esc_html() は SQL インジェクション対策ではないという点です。前者は入力の整形、後者は HTML 出力時のエスケープが目的で、SQL の文脈で安全にする関数ではありません。目的ごとに使う関数を分けて考えます。
基本は $wpdb->prepare() を使う
$wpdb->prepare() は、SQL のひな形とプレースホルダー、値を受け取り、値を安全な形に変換して埋め込みます。
// 良い例:プレースホルダーで値を渡す
$id = absint( $_GET['id'] ?? 0 );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID = %d AND post_status = %s",
$id,
'publish'
)
);
使えるプレースホルダーは次のとおりです(2026年10月現在)。
%d:整数%f:浮動小数点数%s:文字列(引用符は prepare が付けます)%i:テーブル名や列名などの識別子(WordPress 6.2 以降)
書くときの注意点がいくつかあります。まず、'%s' のようにプレースホルダーを自分で引用符で囲む必要はありません。引用符は prepare が付けるため、囲まずに書くのが公式ドキュメントの書き方です。次に、SQL の中に文字としての % を書く場合は %% と書きます。また、プレースホルダーが1つもない SQL を prepare に渡すと、開発時に「使い方が誤っている」旨の通知が出ます。値を埋め込まない固定の SQL なら prepare は不要です。
{$wpdb->posts} や {$wpdb->prefix} のようなテーブル名の埋め込みは、WordPress 自身が決める値なので問題ありません。危険なのは、外部から来た値を同じように埋め込むことです。
つまずきやすい3つの書き方
LIKE 検索は esc_like() と組み合わせる
LIKE 検索では % と _ がワイルドカードになるため、入力に含まれるこれらの文字を先にエスケープします。
$keyword = sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) );
$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 LIMIT %d",
$like,
'publish',
20
)
);
esc_like() はワイルドカードを文字として扱うための処理で、それだけでは SQL インジェクション対策になりません。prepare の %s と組み合わせて初めて安全になります。
IN 句はプレースホルダーを個数分つくる
複数の ID で絞り込む場合、配列を implode() で連結して埋め込むのは危険です。プレースホルダーを値の個数だけ並べます。
$ids = array_filter( array_map( 'absint', (array) ( $_POST['ids'] ?? array() ) ) );
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 を並べただけの文字列なので、SQL に埋め込んでも問題ありません。配列が空のときに IN () という不正な SQL にならないよう、事前に件数を確認している点もポイントです。
ORDER BY は許可リストで決める
並べ替えの列や方向は値ではなく SQL の構文の一部なので、%s では扱えません。受け取った値を許可リストと照合し、許可された値だけを使います。
$allowed_columns = array( 'post_date', 'post_title', 'ID' );
$orderby = in_array( $_GET['orderby'] ?? '', $allowed_columns, true ) ? $_GET['orderby'] : 'post_date';
$order = ( strtoupper( $_GET['order'] ?? '' ) === 'ASC' ) ? 'ASC' : 'DESC';
$rows = $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
)
);
WordPress 6.2 以降なら列名は %i で渡せますが、%i は識別子として正しく引用するだけで、どの列を許すかは判断しません。許可リストとの照合は省略しないでください。ASC/DESC は識別子ではないため、上のように2択から選びます。
そもそも SQL を書かない選択肢
投稿やユーザー、メタデータの取得であれば、WP_Query、get_posts()、get_user_meta() などの API を使うほうが、エスケープもキャッシュも WordPress に任せられます。独自テーブルへの書き込みも、$wpdb->insert()、$wpdb->update()、$wpdb->delete() を使うと値のエスケープが自動で行われます。
$wpdb->insert(
$wpdb->prefix . 'my_logs',
array(
'user_id' => get_current_user_id(),
'action' => sanitize_key( $_POST['log_action'] ?? '' ),
),
array( '%d', '%s' )
);
第3引数のフォーマット指定(%d、%s)を省略すると値はすべて文字列として扱われるため、型を明示しておくのがおすすめです。esc_sql() という関数もありますが、引用符で囲む処理は自分で書く必要があり、使い方を誤りやすいため、prepare が使える場面では prepare を優先します。
Ajax や REST API ではセットで守る
SQL インジェクション対策は、権限チェックや nonce と組み合わせて初めて実用的になります。管理画面からの Ajax 処理なら、次の順で検証します。
add_action( 'wp_ajax_my_search', function () {
check_ajax_referer( 'my_search', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'forbidden', 403 );
}
global $wpdb;
$keyword = sanitize_text_field( wp_unslash( $_POST['keyword'] ?? '' ) );
$like = '%' . $wpdb->esc_like( $keyword ) . '%';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s LIMIT %d",
$like,
20
)
);
wp_send_json_success( $rows );
} );
nonce は「本人の操作か」、権限チェックは「その操作をしてよい人か」、prepare は「入力が SQL として解釈されないか」をそれぞれ確かめます。どれか1つで他を代用することはできません。取得したデータを画面に出すときは、esc_html() などで出力時にエスケープします。
既存コードを点検する方法
自作のプラグインやテーマを点検するときは、まず $wpdb->query、get_results、get_var、get_row、get_col を検索し、引数の SQL に $_GET、$_POST、$_REQUEST 由来の変数が prepare を通らずに入っていないかを見ます。変数が関数をまたいで渡されている場合は、呼び出し元までさかのぼって確認します。
機械的な点検には、WordPress Coding Standards(PHP_CodeSniffer 用のルール集)が役立ちます。prepare を通していない SQL や、プレースホルダーの数と引数の数の不一致を警告してくれます。公式ディレクトリへの登録を予定しているなら、Plugin Check プラグインでも同様の観点を確認できます。ルールの名前や検出内容はバージョンで変わることがあるため、導入時に各プロジェクトのドキュメントを確認してください。
データベース側で被害を小さくする
コードの対策に加えて、万一の被害を小さくする設定も行っておきます。
- データベースユーザーの権限を絞る:WordPress 用のユーザーには対象データベースだけの権限を与え、他のデータベースやサーバー全体の管理権限は付けません
- エラーを画面に出さない:本番では
WP_DEBUG_DISPLAYを false にし、SQL エラーの内容が訪問者に見えないようにします。エラー文は攻撃者の手がかりになります - プラグインとテーマを更新する:既知の SQL インジェクション脆弱性は、更新で修正されることが多くあります。使っていないプラグインは削除します
- WAF を補助として使う:サーバーや CDN の WAF は攻撃の一部を遮断できますが、コードの修正の代わりにはなりません
まとめ
- ユーザー入力は SQL に連結せず、
$wpdb->prepare()の%d・%f・%s・%iで渡します。プレースホルダーは引用符で囲みません - LIKE は
esc_like()と%s、IN 句は個数分のプレースホルダー、ORDER BY は許可リストで扱います - 可能なら WP_Query や
$wpdb->insert()などの API を使い、SQL を自分で組み立てる箇所を減らします - nonce・権限チェック・prepare・出力時のエスケープはそれぞれ役割が違うため、組み合わせて使います
- データベースユーザーの権限を絞り、エラー表示を止め、更新を続けて被害の範囲を小さくします