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などで既存コードを点検する

お気軽にご相談ください

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