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・出力時のエスケープも合わせて行います。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲