wordpress-security-validation について
WordPressとPHPの開発者向けセキュリティスキルです。入力と出力を段階的に扱う方法を示し、CSRF、XSS、SQLインジェクションを防ぐパターンを紹介します。
主な機能
- 01入力サニタイズのパターン
- 02ビジネスロジックの検証
- 03コンテキスト別の出力エスケープ
- 04nonceによるCSRF対策
- 05WordPressセキュリティ関数の例
活用シーン
- 01プラグインのフォーム送信の保護
- 02nonceを使ったAJAX操作の保護
- 03ユーザー入力と画面出力の処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill wordpress-security-validationインストール後、Claude Code で/wordpress-security-validationを入力して読み込まれたか確認します。
同梱ファイルmetadata.jsonreferences/php-quality-antipatterns.md
詳細
WordPress のセキュリティとデータ検証
バージョン: 1.1.0 対象: WordPress 6.7+ | PHP 8.3+ スキルレベル: 中級から上級
概要
WordPress 開発において、セキュリティは任意ではなく、不可欠な要素です。このスキルでは、適切な入力のサニタイズ、ビジネスロジックの検証、出力のエスケープによって、XSS、CSRF、SQL インジェクション、その他の一般的なウェブ脆弱性を防ぐ3 層のセキュリティモデルを学びます。
黄金律:「入力時にサニタイズし、ロジックに照らして検証し、出力時にエスケープする。」
なぜ重要なのか
毎年、プラグインやテーマのセキュリティ脆弱性が原因で、何千もの WordPress サイトが侵害されています。こうした攻撃の多くは、次の 3 つの弱点のいずれかを悪用します。
- XSS(クロスサイトスクリプティング): サニタイズされていない出力を通じて悪意のある JavaScript が挿入される
- CSRF(クロスサイトリクエストフォージェリ): 認証済みユーザーに代わって不正な操作が実行される
- SQL インジェクション: サニタイズされていないデータベースクエリを通じてデータベースが操作される
このスキルでは、3 つすべての攻撃経路を防ぐための、本番環境ですぐに使える包括的なパターンを紹介します。
3 層のセキュリティモデル
WordPress のセキュリティでは、明確に分かれた 3 つの層による多層防御戦略を採用します。
User Input → [1. SANITIZE] → [2. VALIDATE] → Process → [3. ESCAPE] → Output
第 1 層:サニタイズ(入力のクリーニング)
目的: 危険な文字を取り除き、データ形式を正規化する
タイミング: ユーザー入力を受け取った直後
例: sanitize_text_field($_POST['username'])
第 2 層:検証(ロジックチェック)
目的: データがビジネス要件を満たしていることを確認する
タイミング: サニタイズ後、処理前
例: if (!is_email($email)) { /* error */ }
第 3 層:エスケープ(出力の保護)
目的: 特殊文字をエンコードして XSS を防ぐ
タイミング: ブラウザーにデータを出力するたび
例: echo esc_html($user_input);
重要な違い:
- サニタイズは無効なデータを削除または変換します(値が変わります)
- 検証はデータが許容可能かどうかを確認します(true/false を返します)
- エスケープはデータを安全に表示できるようにします(コンテキストに応じてエンコードします)
1. ノンス:CSRF 対策
ノンスとは?
ノンス(Numbers Used Once)は、リクエストが悪意のある外部ソースではなく、自サイトから送信されたことを確認する暗号化トークンです。**クロスサイトリクエストフォージェリ(CSRF)**攻撃を防ぎます。
CSRF 攻撃の仕組み:
<img src="https://yoursite.com/wp-admin/admin.php?action=delete_user&id=1">
ノンスが CSRF を防ぐ仕組み:
<form action="admin.php?action=delete_user&id=1" method="POST">
<?php wp_nonce_field('delete_user_1', 'delete_nonce'); ?>
<button>Delete User</button>
</form>
ノンスの実装パターン
パターン 1:フォームのノンス(最も一般的)
変更前(脆弱):
// Vulnerable form processing
if (isset($_POST['submit'])) {
$user_id = absint($_POST['user_id']);
delete_user($user_id); // ⚠️ CSRF vulnerable!
}
変更後(安全):
// Generate nonce in form
<form method="post" action="">
<?php wp_nonce_field('delete_user_action', 'delete_user_nonce'); ?>
<input type="hidden" name="user_id" value="42">
<button type="submit" name="submit">Delete User</button>
</form>
// Verify nonce on submission
if (isset($_POST['submit'])) {
// Security check #1: Verify nonce
if (!isset($_POST['delete_user_nonce']) ||
!wp_verify_nonce($_POST['delete_user_nonce'], 'delete_user_action')) {
wp_die('Security check failed: Invalid nonce');
}
// Security check #2: Capability check
if (!current_user_can('delete_users')) {
wp_die('You do not have permission to delete users');
}
// Now safe to process
$user_id = absint($_POST['user_id']);
wp_delete_user($user_id);
}
主な関数:
wp_nonce_field($action, $name)- 非表示のノンスフィールドを生成しますwp_verify_nonce($nonce, $action)- ノンスの有効性を検証します
パターン 2:URL のノンス
用途: 削除/ゴミ箱への移動リンク、管理操作
// Generate nonce URL
$delete_url = wp_nonce_url(
admin_url('admin.php?action=delete_post&post_id=123'),
'delete_post_123', // Action (must be unique)
'delete_nonce' // Query parameter name
);
echo '<a href="' . esc_url($delete_url) . '">Delete Post</a>';
// Verify nonce in handler
add_action('admin_action_delete_post', 'handle_delete_post');
function handle_delete_post() {
// Verify nonce from URL
if (!isset($_GET['delete_nonce']) ||
!wp_verify_nonce($_GET['delete_nonce'], 'delete_post_123')) {
wp_die('Invalid security token');
}
// Verify capability
$post_id = absint($_GET['post_id']);
if (!current_user_can('delete_post', $post_id)) {
wp_die('You cannot delete this post');
}
// Delete post
wp_delete_post($post_id, true); // true = force delete
// Redirect with success message
wp_redirect(add_query_arg('message', 'deleted', wp_get_referer()));
exit;
}
パターン 3:AJAX のノンス
用途: フロントエンドの AJAX リクエスト
変更前(脆弱):
// ⚠️ Vulnerable AJAX request
jQuery.post(ajaxurl, {
action: 'update_user_meta',
user_id: 42,
meta_key: 'favorite_color',
meta_value: 'blue'
}, function(response) {
console.log(response);
});
変更後(安全):
PHP(ノンス付きでスクリプトを登録):
add_action('wp_enqueue_scripts', 'enqueue_ajax_script');
function enqueue_ajax_script() {
wp_enqueue_script('my-ajax-script',
plugin_dir_url(__FILE__) . 'js/ajax.js',
['jquery'],
'1.0.0',
true
);
// Pass nonce and AJAX URL to JavaScript
wp_localize_script('my-ajax-script', 'myAjax', [
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('my_ajax_nonce'), // Generate nonce
]);
}
// AJAX handler with nonce verification
add_action('wp_ajax_update_user_meta', 'handle_ajax_update');
function handle_ajax_update() {
// Verify nonce
check_ajax_referer('my_ajax_nonce', 'nonce');
// Verify capability
if (!current_user_can('edit_users')) {
wp_send_json_error(['message' => 'Permission denied']);
}
// Sanitize input
$user_id = absint($_POST['user_id']);
$meta_key = sanitize_key($_POST['meta_key']);
$meta_value = sanitize_text_field($_POST['meta_value']);
// Update meta
update_user_meta($user_id, $meta_key, $meta_value);
wp_send_json_success(['message' => 'Updated successfully']);
}
JavaScript(AJAX でノンスを使用):
jQuery(document).ready(function($) {
$('#update-button').on('click', function() {
$.post(myAjax.ajaxurl, {
action: 'update_user_meta',
nonce: myAjax.nonce, // Include nonce
user_id: 42,
meta_key: 'favorite_color',
meta_value: 'blue'
}, function(response) {
if (response.success) {
console.log(response.data.message);
} else {
console.error(response.data.message);
}
});
});
});
主な関数:
wp_create_nonce($action)- ノンストークンを生成しますcheck_ajax_referer($action, $query_arg)- AJAX ノンスを検証します(失敗時に処理を終了します)wp_send_json_success($data)- JSON の成功レスポンスを送信しますwp_send_json_error($data)- JSON のエラーレスポンスを送信します
ノンスのベストプラクティス
✅ 推奨事項:
- 固有のアクション名を使用する(例:
delete_post_$post_id。単にdeleteとしない) - データを処理する前に、必ずノンスを検証する
- ノンスのチェックと権限のチェックを組み合わせる
- 用途に合ったノンス関数を使用する(AJAX では
check_ajax_referer)
❌ 避けること:
- 複数の操作に同じノンスアクションを再利用する
- 「読み取り専用」の操作でノンス検証を省略する
- ノンス検証だけを信頼する(必ず権限も確認する)
- ノンスを長期間使用するために Cookie や URL に保存する(有効期限があります)
ノンスの有効期間: WordPress のノンスは、デフォルトで 24 時間後に期限切れになります(時間枠の前後それぞれ 12 時間)。
2. サニタイズ関数リファレンス
サニタイズは、危険な文字を削除またはエンコードして、ユーザー入力を安全な形式に変換します。これは、悪意のあるデータに対する最初の防御線です。
主なサニタイズ関数
| 関数 | 使用例 | 入力例 | 出力 |
|---|---|---|---|
sanitize_text_field() | 1行のテキスト(ユーザー名、タイトル) | "Hello <script>alert('xss')</script>" | "Hello alert('xss')" |
sanitize_email() | メールアドレス | "[email protected]<script>" | "[email protected]" |
sanitize_url() / esc_url_raw() | URL(保存用) | "javascript:alert('xss')" | ""(ブロック) |
sanitize_key() | 配列キー、メタキー | "my key!" | "my_key" |
sanitize_file_name() | ファイルアップロード | "../../etc/passwd" | "..etcpasswd" |
absint() | 正の整数 | "-5", "42abc" | 5, 42 |
intval() | 任意の整数 | "-5", "42.7" | -5, 42 |
floatval() | 浮動小数点数 | "3.14abc" | 3.14 |
wp_kses_post() | HTML コンテンツ(安全なタグを許可) | "<p>Safe</p><script>Bad</script>" | "<p>Safe</p>" |
wp_kses() | 独自に許可したタグを含む HTML | 以下を参照 | 独自のフィルタリング |
sanitize_textarea_field() | 複数行のテキスト | "Line 1\nLine 2<script>" | "Line 1\nLine 2" |
sanitize_title() | 投稿スラッグ | "Hello World!" | "hello-world" |
詳細な例
テキストのサニタイズ
// Single-line text (removes HTML, line breaks, extra whitespace)
$username = sanitize_text_field($_POST['username']);
// Input: " John <b>Doe</b>\n"
// Output: "John Doe"
// Multi-line text (preserves line breaks, removes HTML)
$bio = sanitize_textarea_field($_POST['bio']);
// Input: "Line 1\nLine 2<script>alert('xss')</script>"
// Output: "Line 1\nLine 2alert('xss')"
// Email (validates format and removes invalid characters)
$email = sanitize_email($_POST['email']);
// Input: "[email protected] <script>"
// Output: "[email protected]"
// URL (removes dangerous protocols)
$website = esc_url_raw($_POST['website']);
// Input: "javascript:alert('xss')"
// Output: "" (blocked protocol)
// Input: "http://example.com"
// Output: "http://example.com"
数値のサニタイズ
// Positive integers only (absolute value)
$post_id = absint($_POST['post_id']);
// Input: "-5", "42", "123abc"
// Output: 5, 42, 123
// Any integer (preserves negative)
$temperature = intval($_POST['temperature']);
// Input: "-5", "42.7", "99abc"
// Output: -5, 42, 99
// Floating-point numbers
$price = floatval($_POST['price']);
// Input: "19.99", "20.5abc"
// Output: 19.99, 20.5
HTML のサニタイズ
wp_kses_post() - WordPress の投稿エディターで使われるタグを許可:
$content = wp_kses_post($_POST['content']);
// Allows: <p>, <a>, <strong>, <em>, <ul>, <ol>, <li>, <blockquote>, <img>, etc.
// Blocks: <script>, <iframe>, <object>, <embed>, <form>
// Input: "<p>Safe content</p><script>alert('xss')</script>"
// Output: "<p>Safe content</p>alert('xss')"
wp_kses() - 独自に許可したタグ:
// Define allowed tags and attributes
$allowed_html = [
'a' => [
'href' => true,
'title' => true,
'target' => true,
],
'strong' => [],
'em' => [],
'br' => [],
];
$clean_html = wp_kses($_POST['content'], $allowed_html);
// Input: "<a href='#' onclick='alert(1)'>Link</a><script>Bad</script>"
// Output: "<a href='#'>Link</a>Bad" (onclick removed, script stripped)
すべての HTML を除去:
$plain_text = wp_strip_all_tags($_POST['content']);
// Input: "<p>Hello <b>World</b></p>"
// Output: "Hello World"
ファイルアップロードのサニタイズ
// Sanitize filename (removes path traversal, special characters)
$safe_filename = sanitize_file_name($_FILES['upload']['name']);
// Input: "../../etc/passwd", "my file!.php"
// Output: "..etcpasswd", "my-file.php"
// Complete file upload example
if (isset($_FILES['user_avatar'])) {
// Verify nonce first!
if (!wp_verify_nonce($_POST['upload_nonce'], 'upload_avatar')) {
wp_die('Security check failed');
}
// Sanitize filename
$filename = sanitize_file_name($_FILES['user_avatar']['name']);
// Validate file type
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
$file_type = $_FILES['user_avatar']['type'];
if (!in_array($file_type, $allowed_types)) {
wp_die('Invalid file type. Only JPG, PNG, GIF allowed.');
}
// Use WordPress upload handler (handles security)
$upload = wp_handle_upload($_FILES['user_avatar'], [
'test_form' => false,
'mimes' => [
'jpg|jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
],
]);
if (isset($upload['error'])) {
wp_die('Upload failed: ' . $upload['error']);
}
// Store uploaded file URL
$avatar_url = $upload['url'];
update_user_meta(get_current_user_id(), 'avatar_url', $avatar_url);
}
配列のサニタイズ
// Sanitize array of text fields
$tags = array_map('sanitize_text_field', $_POST['tags']);
// Input: ['tag1', '<script>tag2</script>', 'tag3']
// Output: ['tag1', 'tag2', 'tag3']
// Sanitize array of integers
$ids = array_map('absint', $_POST['post_ids']);
// Input: ['1', '2abc', '-5']
// Output: [1, 2, 5]
// Sanitize array of emails
$emails = array_map('sanitize_email', $_POST['email_list']);
カスタムサニタイズコールバック
// Register setting with sanitization callback
register_setting('my_plugin_options', 'my_plugin_settings', [
'type' => 'array',
'sanitize_callback' => 'my_plugin_sanitize_settings',
]);
function my_plugin_sanitize_settings($input) {
$sanitized = [];
// Sanitize API key (alphanumeric only)
if (isset($input['api_key'])) {
$sanitized['api_key'] = preg_replace('/[^a-zA-Z0-9]/', '', $input['api_key']);
}
// Sanitize boolean checkbox
$sanitized['enable_feature'] = isset($input['enable_feature']) ? 1 : 0;
// Sanitize color (hex format)
if (isset($input['primary_color'])) {
$color = sanitize_hex_color($input['primary_color']);
$sanitized['primary_color'] = $color ? $color : '#000000';
}
// Sanitize select option (whitelist)
$allowed_modes = ['mode1', 'mode2', 'mode3'];
if (isset($input['mode']) && in_array($input['mode'], $allowed_modes)) {
$sanitized['mode'] = $input['mode'];
} else {
$sanitized['mode'] = 'mode1'; // Default
}
return $sanitized;
}
3. バリデーションのパターン
バリデーションは、サニタイズ後のデータがビジネスロジックの要件を満たしているかを確認します。データを変換するサニタイズとは異なり、バリデーションはtrue または false を返します。
組み込みのバリデーション関数
| 関数 | 目的 | 例 |
|---|---|---|
is_email($email) | 有効なメールアドレス形式 | is_email('[email protected]') → true |
is_numeric($value) | 数値文字列 | is_numeric('42') → true |
is_int($value) | 整数型 | is_int(42) → true |
is_array($value) | 配列型 | is_array([1,2,3]) → true |
is_user_logged_in() | ユーザー認証 | is_user_logged_in() → true/false |
username_exists($user) | ユーザー名が存在するか | username_exists('admin') → user_id または null |
email_exists($email) | メールアドレスが存在するか | email_exists('[email protected]') → user_id または false |
バリデーションの例
メールアドレスのバリデーション
$email = sanitize_email($_POST['email']);
// Validate format
if (!is_email($email)) {
$errors[] = 'Invalid email address format';
}
// Validate uniqueness (for registration)
if (email_exists($email)) {
$errors[] = 'Email address already registered';
}
数値範囲のバリデーション
$age = absint($_POST['age']);
// Validate range
if ($age < 18 || $age > 100) {
$errors[] = 'Age must be between 18 and 100';
}
// Validate positive number
if ($quantity <= 0) {
$errors[] = 'Quantity must be greater than zero';
}
文字列長のバリデーション
$username = sanitize_text_field($_POST['username']);
// Validate minimum length
if (strlen($username) < 3) {
$errors[] = 'Username must be at least 3 characters';
}
// Validate maximum length
if (strlen($username) > 20) {
$errors[] = 'Username cannot exceed 20 characters';
}
必須フィールドのバリデーション
// Check if field exists and is not empty
if (empty($_POST['title']) || trim($_POST['title']) === '') {
$errors[] = 'Title is required';
}
// Alternative: isset() + non-empty check
if (!isset($_POST['terms']) || $_POST['terms'] !== 'accepted') {
$errors[] = 'You must accept the terms and conditions';
}
パターンマッチング(正規表現)
$phone = sanitize_text_field($_POST['phone']);
// Validate phone format (US format: (555) 123-4567)
if (!preg_match('/^\(\d{3}\) \d{3}-\d{4}$/', $phone)) {
$errors[] = 'Phone must be in format: (555) 123-4567';
}
// Validate alphanumeric only
$product_code = sanitize_text_field($_POST['product_code']);
if (!preg_match('/^[a-zA-Z0-9]+$/', $product_code)) {
$errors[] = 'Product code must contain only letters and numbers';
}
複数フィールドのバリデーション
function validate_registration_form($data) {
$errors = [];
// Email validation
$email = sanitize_email($data['email']);
if (!is_email($email)) {
$errors['email'] = 'Invalid email address';
} elseif (email_exists($email)) {
$errors['email'] = 'Email already registered';
}
// Username validation
$username = sanitize_text_field($data['username']);
if (strlen($username) < 3) {
$errors['username'] = 'Username too short (minimum 3 characters)';
} elseif (username_exists($username)) {
$errors['username'] = 'Username already taken';
}
// Password validation
if (strlen($data['password']) < 8) {
$errors['password'] = 'Password must be at least 8 characters'; // pragma: allowlist secret
}
// Password confirmation
if ($data['password'] !== $data['password_confirm']) {
$errors['password_confirm'] = 'Passwords do not match'; // pragma: allowlist secret
}
// Age validation
$age = absint($data['age']);
if ($age < 18) {
$errors['age'] = 'You must be 18 or older to register';
}
return empty($errors) ? true : $errors;
}
// Usage
$result = validate_registration_form($_POST);
if ($result === true) {
// Process registration
} else {
// Display errors
foreach ($result as $field => $error) {
echo "<p class='error'>$error</p>";
}
}
カスタムバリデーションルール
// Validate URL is from allowed domain
function validate_allowed_domain($url) {
$allowed_domains = ['example.com', 'wordpress.org'];
$host = parse_url($url, PHP_URL_HOST);
return in_array($host, $allowed_domains);
}
// Validate date format and range
function validate_date($date_string) {
$date = DateTime::createFromFormat('Y-m-d', $date_string);
if (!$date) {
return false; // Invalid format
}
// Check date is not in the past
$now = new DateTime();
if ($date < $now) {
return false;
}
return true;
}
// Validate credit card (Luhn algorithm)
function validate_credit_card($number) {
$number = preg_replace('/\D/', '', $number); // Remove non-digits
if (strlen($number) < 13 || strlen($number) > 19) {
return false;
}
$sum = 0;
$double = false;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$digit = (int) $number[$i];
if ($double) {
$digit *= 2;
if ($digit > 9) {
$digit -= 9;
}
}
$sum += $digit;
$double = !$double;
}
return ($sum % 10) === 0;
}
4. 出力のエスケープに関するリファレンス
エスケープは、出力前に特殊文字をエンコードして、XSS(クロスサイトスクリプティング)を防ぎます。これは最後のセキュリティ層です。
主なエスケープ関数
| 関数 | コンテキスト | エスケープ対象 | 使用例 |
|---|---|---|---|
esc_html() | HTML コンテンツ | < > & " ' | echo esc_html($user_input); |
esc_attr() | HTML 属性 | < > & " ' | <input value="<?php echo esc_attr($value); ?>"> |
esc_url() | HTML の href/src | 危険なプロトコル | <a href="<?php echo esc_url($link); ?>"> |
esc_js() | JavaScript 文字列 | ' " \ / | <script>var msg = '<?php echo esc_js($message); ?>';</script> |
esc_sql() | 非推奨($wpdb->prepare() を使用) | SQL の特殊文字 | ❌ 使用しないでください |
esc_textarea() | テキストエリアのコンテンツ | < > & | <textarea><?php echo esc_textarea($content); ?></textarea> |
エスケープの詳細な例
HTML コンテンツのエスケープ
// Escape HTML content (converts special characters to entities)
$user_comment = "<script>alert('XSS')</script>Hello";
echo esc_html($user_comment);
// Output: <script>alert('XSS')</script>Hello
// Browser displays: <script>alert('XSS')</script>Hello (as text, not code)
// WRONG: No escaping
echo $user_comment; // ⚠️ Executes JavaScript!
HTML 属性のエスケープ
// Escape attribute values
$title = 'My "Awesome" Title';
?>
<input type="text"
value="<?php echo esc_attr($title); ?>"
placeholder="<?php echo esc_attr($placeholder); ?>">
<input value="<?php echo $title; ?>">
URL のエスケープ
// Escape URLs (blocks dangerous protocols)
$user_url = "javascript:alert('XSS')";
echo '<a href="' . esc_url($user_url) . '">Link</a>';
// Output: <a href="">Link</a> (javascript: protocol blocked)
// Safe URL
$safe_url = "https://example.com";
echo '<a href="' . esc_url($safe_url) . '">Link</a>';
// Output: <a href="https://example.com">Link</a>
// WRONG: No escaping
echo '<a href="' . $user_url . '">Link</a>'; // ⚠️ XSS vulnerability!
JavaScript のエスケープ
// Escape JavaScript strings
$user_message = "It's \"dangerous\" to trust user input";
?>
<script>
var message = '<?php echo esc_js($user_message); ?>';
alert(message);
</script>
<script>
var message = '<?php echo $user_message; ?>'; // ⚠️ Breaks JavaScript!
</script>
Textarea のエスケープ
// Escape textarea content
$bio = "Line 1\nLine 2 <script>alert('XSS')</script>";
?>
<textarea><?php echo esc_textarea($bio); ?></textarea>
<textarea><?php echo esc_html($bio); ?></textarea>
コンテキストに応じたエスケープ
HTML コンテキスト
// Paragraph content
echo '<p>' . esc_html($user_content) . '</p>';
// Link text
echo '<a href="' . esc_url($url) . '">' . esc_html($link_text) . '</a>';
// Image alt text
echo '<img src="' . esc_url($image_url) . '" alt="' . esc_attr($alt_text) . '">';
属性コンテキスト
// Data attributes
echo '<div data-user-id="' . esc_attr($user_id) . '"
data-username="' . esc_attr($username) . '"></div>';
// Class names (use sanitize_html_class)
echo '<div class="' . esc_attr(sanitize_html_class($class)) . '"></div>';
// Style attribute (dangerous - avoid if possible)
$safe_color = sanitize_hex_color($user_color); // Validate first
echo '<div style="color: ' . esc_attr($safe_color) . ';"></div>';
JavaScript コンテキスト
// Inline JavaScript (avoid if possible, use wp_localize_script instead)
<script>
var config = {
username: '<?php echo esc_js($username); ?>',
apiUrl: '<?php echo esc_js(admin_url('admin-ajax.php')); ?>'
};
</script>
// BETTER: Use wp_localize_script
wp_localize_script('my-script', 'myConfig', [
'username' => $username, // Automatically JSON-encoded
'apiUrl' => admin_url('admin-ajax.php'),
]);
国際化とエスケープ
// Translate and escape
echo esc_html__('Welcome User', 'my-plugin');
// Translate with variable, then escape
$message = sprintf(
__('Hello %s, you have %d new messages', 'my-plugin'),
esc_html($username),
absint($message_count)
);
echo $message;
// Escape translatable attributes
<input placeholder="<?php echo esc_attr__('Enter your name', 'my-plugin'); ?>">
// Allow HTML in translations (use wp_kses_post)
$welcome_html = __('Welcome to <strong>My Plugin</strong>!', 'my-plugin');
echo wp_kses_post($welcome_html);
よくあるエスケープの間違い
❌ 誤り:
// Double-escaping (displays HTML entities to user)
echo esc_html(esc_html($content)); // ⚠️ Displays &lt;script&gt;
// Wrong function for context
echo '<a href="' . esc_html($url) . '">Link</a>'; // ⚠️ Use esc_url()
// No escaping in JavaScript
echo "<script>var x = '$user_input';</script>"; // ⚠️ Use esc_js()
// Escaping before storage (store raw, escape on output)
update_option('setting', esc_html($value)); // ⚠️ Escape on output, not input
✅ 正しい例:
// Escape once, on output
echo esc_html($content);
// Use correct function for context
echo '<a href="' . esc_url($url) . '">' . esc_html($text) . '</a>';
// Escape JavaScript properly
wp_localize_script('script', 'data', ['value' => $user_input]);
// Store raw, escape on output
update_option('setting', $value); // Store raw
echo esc_html(get_option('setting')); // Escape on output
5. 権限チェック(認可)
権限チェックにより、ユーザーが操作を実行する権限を持っていることを確認できます。必ず nonce の検証と組み合わせてください。
組み込みの権限
| 権限 | 説明 | デフォルトのロール |
|---|---|---|
read | コンテンツを表示する | ログイン済みのすべてのユーザー |
edit_posts | 自分の投稿を作成・編集する | 投稿者、編集者、管理者 |
edit_published_posts | 公開済みの投稿を編集する | 編集者、管理者 |
delete_posts | 自分の投稿を削除する | 投稿者、編集者、管理者 |
manage_options | サイト設定を管理する | 管理者のみ |
upload_files | メディアをアップロードする | 投稿者、編集者、管理者 |
edit_users | ユーザーアカウントを編集する | 管理者のみ |
delete_users | ユーザーを削除する | 管理者のみ |
install_plugins | プラグインをインストール・有効化する | 管理者のみ |
switch_themes | テーマを変更する | 管理者のみ |
権限チェックのパターン
基本的な権限チェック
// Check if user is logged in
if (!is_user_logged_in()) {
wp_die('You must be logged in to access this page');
}
// Check if user has capability
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to manage settings');
}
// Check if user can edit specific post
$post_id = absint($_GET['post_id']);
if (!current_user_can('edit_post', $post_id)) {
wp_die('You cannot edit this post');
}
セキュリティ対策の完全な例
add_action('admin_post_update_settings', 'handle_settings_update');
function handle_settings_update() {
// 1. Check if user is logged in
if (!is_user_logged_in()) {
wp_die('You must be logged in');
}
// 2. Verify nonce
if (!isset($_POST['settings_nonce']) ||
!wp_verify_nonce($_POST['settings_nonce'], 'update_settings')) {
wp_die('Security check failed');
}
// 3. Check user capability
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to update settings');
}
// 4. Sanitize input
$api_key = sanitize_text_field($_POST['api_key']);
$enable_feature = isset($_POST['enable_feature']) ? 1 : 0;
// 5. Validate data
if (strlen($api_key) < 10) {
wp_die('API key must be at least 10 characters');
}
// 6. Update options
update_option('my_plugin_api_key', $api_key);
update_option('my_plugin_enable_feature', $enable_feature);
// 7. Redirect with success message
wp_redirect(add_query_arg('message', 'updated', wp_get_referer()));
exit;
}
投稿固有の権限
// Check if user can edit specific post
$post_id = absint($_POST['post_id']);
if (!current_user_can('edit_post', $post_id)) {
wp_send_json_error(['message' => 'You cannot edit this post']);
}
// Check if user can delete specific post
if (!current_user_can('delete_post', $post_id)) {
wp_send_json_error(['message' => 'You cannot delete this post']);
}
// Check if user can publish posts
if (!current_user_can('publish_posts')) {
wp_send_json_error(['message' => 'You cannot publish posts']);
}
カスタム権限
// Register custom role with custom capability
add_action('init', 'register_custom_role');
function register_custom_role() {
add_role('store_manager', 'Store Manager', [
'read' => true,
'edit_posts' => true,
'manage_products' => true, // Custom capability
]);
}
// Add custom capability to existing role
$role = get_role('editor');
$role->add_cap('manage_products');
// Check custom capability
if (current_user_can('manage_products')) {
// Allow product management
}
6. SQL インジェクションの防止
重大: SQL クエリでは、ユーザー入力を決して信用しないでください。必ず $wpdb->prepare() を使用してください。
問題:SQL インジェクション
変更前(脆弱):
global $wpdb;
// ⚠️ CRITICAL VULNERABILITY - SQL INJECTION!
$user_id = $_GET['user_id'];
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_author = $user_id"
);
// Attacker can inject SQL:
// ?user_id=1 OR 1=1 -- (returns all posts)
// ?user_id=1; DROP TABLE wp_posts; -- (deletes table!)
変更後(安全):
global $wpdb;
// ✅ SECURE - Using prepared statements
$user_id = absint($_GET['user_id']); // Sanitize first
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_author = %d",
$user_id
)
);
プリペアドステートメントのプレースホルダー
| プレースホルダー | 型 | 例 |
|---|---|---|
%s | 文字列 | "SELECT * FROM table WHERE name = %s" |
%d | 整数 | "SELECT * FROM table WHERE id = %d" |
%f | 浮動小数点数 | "SELECT * FROM table WHERE price = %f" |
完全な例
SELECT クエリ
global $wpdb;
$email = sanitize_email($_POST['email']);
// Prepared statement (prevents SQL injection)
$user = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->users} WHERE user_email = %s",
$email
)
);
if ($user) {
echo "User found: " . esc_html($user->user_login);
}
INSERT クエリ
global $wpdb;
// Use wpdb->insert() (automatically prepares)
$result = $wpdb->insert(
$wpdb->prefix . 'my_table',
[
'title' => sanitize_text_field($_POST['title']),
'content' => wp_kses_post($_POST['content']),
'user_id' => absint($_POST['user_id']),
'price' => floatval($_POST['price']),
'created_at' => current_time('mysql'),
],
['%s', '%s', '%d', '%f', '%s'] // Format specifiers
);
if ($result === false) {
wp_die('Database insert failed: ' . $wpdb->last_error);
}
$inserted_id = $wpdb->insert_id;
UPDATE クエリ
global $wpdb;
$wpdb->update(
$wpdb->prefix . 'my_table',
[
'title' => sanitize_text_field($_POST['title']), // New values
'updated_at' => current_time('mysql'),
],
['id' => absint($_POST['id'])], // WHERE condition
['%s', '%s'], // Format for new values
['%d'] // Format for WHERE condition
);
DELETE クエリ
global $wpdb;
$wpdb->delete(
$wpdb->prefix . 'my_table',
['id' => absint($_POST['id'])],
['%d']
);
複雑な WHERE 句
global $wpdb;
$status = sanitize_text_field($_POST['status']);
$min_price = floatval($_POST['min_price']);
// Multiple placeholders
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}products
WHERE status = %s AND price >= %f
ORDER BY created_at DESC
LIMIT %d",
$status,
$min_price,
10 // LIMIT value
)
);
よくある SQL インジェクションの間違い
❌ 誤り:
// String concatenation (vulnerable!)
$sql = "SELECT * FROM table WHERE name = '" . $_POST['name'] . "'";
// Using esc_sql() (deprecated and insufficient)
$sql = "SELECT * FROM table WHERE name = '" . esc_sql($_POST['name']) . "'";
// Not using placeholders
$wpdb->query("DELETE FROM table WHERE id = $id"); // ⚠️ Vulnerable
✅ 正しい例:
// Always use $wpdb->prepare()
$wpdb->get_results($wpdb->prepare(
"SELECT * FROM table WHERE name = %s",
$_POST['name']
));
// Use wpdb methods (insert, update, delete)
$wpdb->insert('table', ['name' => $_POST['name']], ['%s']);
7. よくある脆弱性と攻撃シナリオ
XSS(クロスサイトスクリプティング)
攻撃シナリオ:
// Vulnerable code
echo "Welcome, " . $_GET['username'];
// Attacker visits: ?username=<script>alert(document.cookie)</script>
// Browser executes JavaScript, stealing session cookies
防止策:
// Escape output
echo "Welcome, " . esc_html($_GET['username']);
// Output: Welcome, <script>alert(document.cookie)</script>
CSRF(クロスサイトリクエストフォージェリ)
攻撃シナリオ:
<img src="https://yoursite.com/wp-admin/admin.php?action=delete_all_posts">
防止策:
// Require nonce verification
if (!wp_verify_nonce($_GET['nonce'], 'delete_all_posts')) {
wp_die('Invalid security token');
}
SQL インジェクション
攻撃シナリオ:
// Vulnerable code
$wpdb->query("DELETE FROM posts WHERE id = " . $_GET['id']);
// Attacker visits: ?id=1 OR 1=1
// Deletes ALL posts!
防止策:
// Use prepared statements
$wpdb->query($wpdb->prepare(
"DELETE FROM posts WHERE id = %d",
absint($_GET['id'])
));
ファイルアップロード攻撃
攻撃シナリオ:
// Vulnerable code
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// Attacker uploads: malicious.php
// Executes: https://yoursite.com/uploads/malicious.php
防止策:
// Validate file type and use wp_handle_upload()
$allowed_types = ['image/jpeg', 'image/png'];
if (!in_array($_FILES['file']['type'], $allowed_types)) {
wp_die('Invalid file type');
}
$upload = wp_handle_upload($_FILES['file'], ['test_form' => false]);
パストラバーサル
攻撃シナリオ:
// Vulnerable code
include($_GET['template'] . '.php');
// Attacker visits: ?template=../../../../etc/passwd
防止策:
// Whitelist allowed templates
$allowed_templates = ['template1', 'template2'];
$template = sanitize_file_name($_GET['template']);
if (in_array($template, $allowed_templates)) {
include($template . '.php');
}
8. セキュリティ実装の完全な例
<?php
/**
* Secure Form Handling Example
* Demonstrates all security layers: nonces, sanitization, validation, escaping
*/
// 1. Display Form (with nonce)
function display_user_profile_form() {
$user_id = get_current_user_id();
$user_data = get_user_meta($user_id, 'profile_data', true);
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="update_user_profile">
<?php wp_nonce_field('update_profile_' . $user_id, 'profile_nonce'); ?>
<label>
Display Name:
<input type="text"
name="display_name"
value="<?php echo esc_attr($user_data['display_name'] ?? ''); ?>"
required>
</label>
<label>
Email:
<input type="email"
name="email"
value="<?php echo esc_attr($user_data['email'] ?? ''); ?>"
required>
</label>
<label>
Bio:
<textarea name="bio"><?php echo esc_textarea($user_data['bio'] ?? ''); ?></textarea>
</label>
<label>
Website:
<input type="url"
name="website"
value="<?php echo esc_attr($user_data['website'] ?? ''); ?>">
</label>
<button type="submit">Update Profile</button>
</form>
<?php
}
// 2. Process Form (with full security)
add_action('admin_post_update_user_profile', 'handle_profile_update');
function handle_profile_update() {
// SECURITY LAYER 1: Authentication
if (!is_user_logged_in()) {
wp_die('You must be logged in to update your profile');
}
$user_id = get_current_user_id();
// SECURITY LAYER 2: Nonce Verification
if (!isset($_POST['profile_nonce']) ||
!wp_verify_nonce($_POST['profile_nonce'], 'update_profile_' . $user_id)) {
wp_die('Security check failed: Invalid nonce');
}
// SECURITY LAYER 3: Capability Check
if (!current_user_can('edit_user', $user_id)) {
wp_die('You do not have permission to update this profile');
}
// SECURITY LAYER 4: Sanitization
$display_name = sanitize_text_field($_POST['display_name']);
$email = sanitize_email($_POST['email']);
$bio = sanitize_textarea_field($_POST['bio']);
$website = esc_url_raw($_POST['website']);
// SECURITY LAYER 5: Validation
$errors = [];
if (empty($display_name) || strlen($display_name) < 3) {
$errors[] = 'Display name must be at least 3 characters';
}
if (!is_email($email)) {
$errors[] = 'Invalid email address';
}
if (!empty($website) && !filter_var($website, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid website URL';
}
if (!empty($errors)) {
wp_die(implode('<br>', array_map('esc_html', $errors)));
}
// SECURITY LAYER 6: Process Data
$profile_data = [
'display_name' => $display_name,
'email' => $email,
'bio' => $bio,
'website' => $website,
];
update_user_meta($user_id, 'profile_data', $profile_data);
// SECURITY LAYER 7: Safe Redirect
wp_redirect(add_query_arg('message', 'profile_updated', wp_get_referer()));
exit;
}
// 3. Display Success Message (with escaping)
add_action('admin_notices', 'show_profile_update_notice');
function show_profile_update_notice() {
if (isset($_GET['message']) && $_GET['message'] === 'profile_updated') {
echo '<div class="notice notice-success is-dismissible">';
echo '<p>' . esc_html__('Profile updated successfully!', 'my-plugin') . '</p>';
echo '</div>';
}
}
9. セキュリティチェックリスト
実装する WordPress の機能ごとに、このチェックリストを使用してください。
入力セキュリティ(フォーム、AJAX、API)
- nonce の検証を実装している(
wp_verify_nonce()) - 権限チェックを実行している(
current_user_can()) - すべての入力を適切な関数でサニタイズしている
- すべての入力をビジネスロジックに照らして検証している
- ファイルのアップロードに
wp_handle_upload()を使用している - ファイル形式をブラックリスト方式ではなく、ホワイトリスト方式で指定している
出力セキュリティ(テンプレート、API)
- すべての動的コンテンツを
esc_html()、esc_attr()などでエスケープしている - URL を
esc_url()でエスケープしている - JavaScript の変数に
wp_localize_script()またはesc_js()を使用している - 生の
echo $_POSTやecho $_GETを使用していない
データベースセキュリティ
- すべてのクエリで
$wpdb->prepare()を使用している - SQL で文字列連結を使用していない
-
$wpdb->insert()、$wpdb->update()、$wpdb->delete()を使用している - テーブル名に
$wpdb->prefixを使用している
セッションセキュリティ
- ユーザー認証を確認している(
is_user_logged_in()) - ユーザーのロールを検証している(
current_user_can()) - 機密性の高い操作で再認証を要求している
- セッションデータを GET パラメーターに保存していない
コード品質
-
eval()、assert()、またはcreate_function()を使用していない - ユーザー入力に対して
extract()を使用していない - エラーメッセージからシステム情報が漏えいしない
- 本番環境でデバッグモードが無効になっている(
WP_DEBUG = false) - 本番環境から
phpinfo()/var_dump()/print_r()にアクセスできない(サーバー情報の漏えい) - セキュリティに関わる処理で空の
catchブロックを使っていない — フェイルクローズし、原因をログに記録する - ユーザーが制御できる入力またはアクセス制御に関する入力を扱うすべての
switchに、フェイルクローズするdefaultがある
セキュリティ上の問題を伴うコード品質の欠陥: 本番環境に
phpinfo()が残っていると、サーバー内部情報が漏えいします。空のcatchブロックは、署名や権限のチェックに失敗してもその事実を黙って無視します(フェイルオープン)。defaultのないswitchでは、想定外の入力が未処理のまま通過します(CWE-478)。準拠例と非準拠例については、 PHP Quality Anti-Patterns を参照してください。パターンは CAST Highlight のコード品質指標(https://doc.casthighlight.com/)に基づいています。
10. セキュリティ実装のテスト
手動テストのチェックリスト
1. Nonce の有効期限をテストする:
# Generate form with nonce, wait 25 hours, submit
# Expected: "Security check failed" error
2. CSRF 保護をテストする:
<form action="https://yoursite.com/wp-admin/admin-post.php" method="POST">
<input name="action" value="your_action">
<button>Submit</button>
</form>
3. XSS 防止をテストする:
Input: <script>alert('XSS')</script>
Expected Output: <script>alert('XSS')</script> (as text)
4. SQL インジェクションをテストする:
Input: 1 OR 1=1
Expected: Treats as literal string, no SQL execution
5. 権限の迂回をテストする:
// Log in as subscriber (low-privilege user)
// Try to access admin-only features
// Expected: "You do not have permission" error
自動セキュリティテスト
セキュリティスキャナーをインストールする:
# WPScan (CLI tool)
gem install wpscan
wpscan --url https://yoursite.com --enumerate vp
# Sucuri Security Plugin
wp plugin install sucuri-scanner --activate
PHP Code Sniffer を実行する:
# Check for security issues
vendor/bin/phpcs --standard=WordPress-Extra,WordPress-VIP-Go
11. 関連スキルとリソース
前提条件
- PHP の基礎 - PHP の構文、型、関数の理解
- WordPress プラグインの基礎 - フック、アクション、フィルター、プラグインの構成
応用トピック
- WordPress のテストと QA - セキュリティに重点を置いたテスト戦略
- WordPress REST API - API エンドポイントのセキュリティ
- WordPress のパフォーマンス - セキュアなキャッシュ戦略
外部リソース
テスト用セキュリティプラグイン
- Wordfence Security - ファイアウォールとマルウェアスキャナー
- Sucuri Security - セキュリティ監査と監視
- iThemes Security - セキュリティ強化と監視
クイックリファレンスカード
// ============================================
// NONCES (CSRF Protection)
// ============================================
// Forms
wp_nonce_field('action_name', 'nonce_field_name');
wp_verify_nonce($_POST['nonce_field_name'], 'action_name');
// URLs
wp_nonce_url($url, 'action_name', 'nonce_param');
wp_verify_nonce($_GET['nonce_param'], 'action_name');
// AJAX
wp_create_nonce('ajax_action');
check_ajax_referer('ajax_action', 'nonce');
// ============================================
// SANITIZATION (Input Cleaning)
// ============================================
sanitize_text_field() // Single-line text
sanitize_textarea_field()// Multi-line text
sanitize_email() // Email addresses
esc_url_raw() // URLs (for storage)
sanitize_file_name() // File names
absint() // Positive integers
wp_kses_post() // HTML content
// ============================================
// VALIDATION (Logic Checks)
// ============================================
is_email($email) // Valid email format
is_numeric($value) // Numeric value
strlen($str) >= 3 // Minimum length
preg_match($pattern) // Pattern matching
in_array($value, $allowed) // Whitelist check
// ============================================
// ESCAPING (Output Protection)
// ============================================
esc_html() // HTML content
esc_attr() // HTML attributes
esc_url() // URLs (output)
esc_js() // JavaScript strings
esc_textarea() // Textarea content
// ============================================
// CAPABILITIES (Authorization)
// ============================================
is_user_logged_in()
current_user_can('capability')
current_user_can('edit_post', $post_id)
// ============================================
// SQL INJECTION PREVENTION
// ============================================
$wpdb->prepare("SELECT * FROM table WHERE id = %d", $id);
$wpdb->insert($table, $data, $format);
$wpdb->update($table, $data, $where, $format, $where_format);
忘れないでください: セキュリティは機能ではなく、必須要件です。ユーザー入力を処理したりデータを表示したりするコードはすべて、これらの原則に従う必要があります。判断に迷ったら、サニタイズ、検証、エスケープを行ってください。