# WordPress Plugin Security Checklist Complete security audit checklist for WordPress plugins. Use this when reviewing code for security vulnerabilities. --- ## 1. File Access Protection ### ABSPATH Check **Required in EVERY PHP file**: ```php if ( ! defined( 'ABSPATH' ) ) { exit; } ``` **Why**: Prevents direct file access via URL **Vulnerability**: Remote code execution, information disclosure **Source**: [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#file-organization) ✅ **Check**: - [ ] All `.php` files have ABSPATH check - [ ] Check is at the top of the file (line 2-4) - [ ] Uses `exit` not `die` (WordPress standard) --- ## 2. Sanitization (Input Validation) ### Always Sanitize User Input **Functions to use**: | Input Type | Sanitization Function | |------------|----------------------| | Text field | `sanitize_text_field()` | | Textarea | `sanitize_textarea_field()` | | Email | `sanitize_email()` | | URL | `esc_url_raw()` | | File name | `sanitize_file_name()` | | HTML content | `wp_kses_post()` or `wp_kses()` | | Integer | `absint()` or `intval()` | | Float | `floatval()` | | Key/Slug | `sanitize_key()` | | Title | `sanitize_title()` | **Example**: ```php // ❌ WRONG - No sanitization $name = $_POST['name']; // ✅ CORRECT - Sanitized $name = sanitize_text_field( $_POST['name'] ); ``` ✅ **Check**: - [ ] All `$_POST` values are sanitized - [ ] All `$_GET` values are sanitized - [ ] All `$_REQUEST` values are sanitized - [ ] All `$_COOKIE` values are sanitized - [ ] Correct sanitization function for data type --- ## 3. Escaping (Output Protection) ### Always Escape Output **Functions to use**: | Output Context | Escaping Function | |----------------|-------------------| | HTML content | `esc_html()` | | HTML attribute | `esc_attr()` | | URL | `esc_url()` | | JavaScript | `esc_js()` | | Textarea | `esc_textarea()` | | HTML blocks | `wp_kses_post()` | | Translation | `esc_html__()`, `esc_html_e()`, `esc_attr__()`, `esc_attr_e()` | **Example**: ```php // ❌ WRONG - No escaping echo $user_input; echo 'Link'; // ✅ CORRECT - Escaped echo esc_html( $user_input ); echo 'Link'; ``` ✅ **Check**: - [ ] All variables in HTML are escaped - [ ] All variables in attributes are escaped - [ ] All URLs are escaped - [ ] Correct escaping function for context --- ## 4. Nonces (CSRF Protection) ### Use Nonces for All Forms and AJAX **Form example**: ```php // Add nonce to form wp_nonce_field( 'my_action', 'my_nonce_field' ); // Verify nonce when processing if ( ! wp_verify_nonce( $_POST['my_nonce_field'], 'my_action' ) ) { wp_die( 'Security check failed' ); } ``` **AJAX example**: ```php // Create nonce wp_create_nonce( 'my_ajax_nonce' ); // Verify in AJAX handler check_ajax_referer( 'my_ajax_nonce', 'nonce' ); ``` **URL example**: ```php // Add nonce to URL $url = wp_nonce_url( admin_url( 'admin-post.php?action=my_action' ), 'my_action' ); // Verify nonce if ( ! wp_verify_nonce( $_GET['_wpnonce'], 'my_action' ) ) { wp_die( 'Security check failed' ); } ``` ✅ **Check**: - [ ] All forms have nonce fields - [ ] All form handlers verify nonces - [ ] All AJAX handlers verify nonces - [ ] All admin action URLs have nonces - [ ] Nonce actions are unique and descriptive --- ## 5. Capability Checks (Authorization) ### Always Check User Permissions **Never use `is_admin()`** - it only checks if you're on an admin page, not user permissions! **Correct**: ```php // ❌ WRONG - Only checks admin area if ( is_admin() ) { // Anyone can access this! } // ✅ CORRECT - Checks user capability if ( current_user_can( 'manage_options' ) ) { // Only admins can access } ``` **Common capabilities**: | Capability | Who Has It | |------------|------------| | `manage_options` | Administrator | | `edit_posts` | Editor, Author, Contributor | | `publish_posts` | Editor, Author | | `edit_published_posts` | Editor, Author | | `delete_posts` | Editor, Author | | `upload_files` | Editor, Author | | `read` | All logged-in users | ✅ **Check**: - [ ] All admin pages check capabilities - [ ] All AJAX handlers check capabilities - [ ] All REST endpoints have permission callbacks - [ ] All form handlers check capabilities - [ ] Never rely on `is_admin()` alone --- ## 6. SQL Injection Prevention ### Always Use Prepared Statements **Use `$wpdb->prepare()`**: ```php global $wpdb; // ❌ WRONG - SQL injection vulnerability $results = $wpdb->get_results( "SELECT * FROM table WHERE id = {$id}" ); // ✅ CORRECT - Prepared statement $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM table WHERE id = %d", $id ) ); ``` **Placeholders**: | Type | Placeholder | |------|-------------| | Integer | `%d` | | Float | `%f` | | String | `%s` | ✅ **Check**: - [ ] All `$wpdb` queries use `prepare()` - [ ] Correct placeholder for data type - [ ] Never concatenate variables into SQL - [ ] User input is sanitized before `prepare()` --- ## 7. Unique Prefixing ### Prevent Naming Conflicts **Use 4-5 character prefix for everything**: ```php // Functions function myplug_init() {} // Classes class MyPlug_Admin {} // Constants define( 'MYPLUG_VERSION', '1.0.0' ); // Options get_option( 'myplug_settings' ); // Meta keys update_post_meta( $id, '_myplug_data', $value ); // AJAX actions add_action( 'wp_ajax_myplug_action', 'myplug_ajax_handler' ); // REST routes register_rest_route( 'myplug/v1', '/endpoint', $args ); ``` ✅ **Check**: - [ ] All functions have unique prefix - [ ] All classes have unique prefix - [ ] All constants have unique prefix - [ ] All database options have unique prefix - [ ] All meta keys have unique prefix (start with `_` for hidden) - [ ] All AJAX actions have unique prefix - [ ] All REST namespaces have unique prefix --- ## 8. Asset Loading ### Load Assets Conditionally ```php // ❌ WRONG - Loads everywhere function bad_enqueue() { wp_enqueue_script( 'my-script', $url ); } add_action( 'wp_enqueue_scripts', 'bad_enqueue' ); // ✅ CORRECT - Loads only where needed function good_enqueue() { if ( is_singular( 'book' ) ) { wp_enqueue_script( 'my-script', $url ); } } add_action( 'wp_enqueue_scripts', 'good_enqueue' ); ``` ✅ **Check**: - [ ] Scripts load only on needed pages - [ ] Styles load only on needed pages - [ ] Admin assets use `admin_enqueue_scripts` hook - [ ] Admin assets check `$hook` parameter - [ ] Dependencies are declared (`array( 'jquery' )`) - [ ] Versions are set (for cache busting) --- ## 9. Data Validation ### Validate Before Saving ```php // Example: Validate select field $allowed_values = array( 'option1', 'option2', 'option3' ); $value = sanitize_text_field( $_POST['select_field'] ); if ( ! in_array( $value, $allowed_values, true ) ) { // Invalid value - reject or use default $value = 'option1'; } ``` ✅ **Check**: - [ ] Select/radio values validated against allowed values - [ ] Number fields validated for min/max range - [ ] Email fields validated with `is_email()` - [ ] URLs validated with `esc_url_raw()` - [ ] File uploads validated for type and size --- ## 10. File Upload Security ### Validate File Uploads ```php // Check file type $allowed_types = array( 'image/jpeg', 'image/png' ); if ( ! in_array( $_FILES['file']['type'], $allowed_types, true ) ) { wp_die( 'Invalid file type' ); } // Check file size $max_size = 5 * 1024 * 1024; // 5MB if ( $_FILES['file']['size'] > $max_size ) { wp_die( 'File too large' ); } // Use WordPress upload handler $file = $_FILES['file']; $upload = wp_handle_upload( $file, array( 'test_form' => false ) ); ``` ✅ **Check**: - [ ] File type is validated - [ ] File size is validated - [ ] Uses `wp_handle_upload()` or `media_handle_upload()` - [ ] File names are sanitized - [ ] User has `upload_files` capability --- ## 11. Direct Object Reference ### Check Ownership Before Actions ```php // ❌ WRONG - No ownership check $post_id = absint( $_POST['post_id'] ); wp_delete_post( $post_id ); // ✅ CORRECT - Check ownership $post_id = absint( $_POST['post_id'] ); $post = get_post( $post_id ); if ( ! $post || $post->post_author != get_current_user_id() ) { wp_die( 'Permission denied' ); } wp_delete_post( $post_id ); ``` ✅ **Check**: - [ ] Delete/edit actions verify ownership - [ ] Or check appropriate capability - [ ] REST endpoints verify ownership in permission callback --- ## 12. REST API Security ### Secure REST Endpoints ```php register_rest_route( 'myplugin/v1', '/items', array( 'methods' => 'POST', 'callback' => 'my_callback', // ✅ REQUIRED: Permission callback 'permission_callback' => function() { return current_user_can( 'edit_posts' ); }, // ✅ REQUIRED: Argument validation 'args' => array( 'title' => array( 'required' => true, 'validate_callback' => function( $param ) { return ! empty( $param ); }, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); ``` ✅ **Check**: - [ ] All endpoints have `permission_callback` - [ ] Never use `'permission_callback' => '__return_true'` for write operations - [ ] All parameters have validation - [ ] All parameters have sanitization - [ ] Return proper HTTP status codes (200, 400, 401, 404, 500) --- ## 13. Internationalization Security ### Escape Translated Strings ```php // ❌ WRONG - Vulnerable to XSS echo __( 'Hello', 'my-plugin' ); // ✅ CORRECT - Escaped echo esc_html__( 'Hello', 'my-plugin' ); echo esc_html_e( 'Hello', 'my-plugin' ); echo esc_attr__( 'Hello', 'my-plugin' ); ``` ✅ **Check**: - [ ] Use `esc_html__()` instead of `__()` - [ ] Use `esc_html_e()` instead of `_e()` - [ ] Use `esc_attr__()` for attributes - [ ] Never output `__()` directly --- ## 14. Data Cleanup ### Remove Data on Uninstall (Not Deactivation) ```php // uninstall.php if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; } // Delete options delete_option( 'myplug_settings' ); // Delete transients delete_transient( 'myplug_cache' ); // Delete posts (optional - user data loss!) // Only if absolutely necessary ``` ✅ **Check**: - [ ] Options deleted in `uninstall.php` - [ ] Transients deleted in `uninstall.php` - [ ] Never delete data in deactivation hook - [ ] Consider asking user before deleting post data --- ## 15. Common Vulnerabilities to Avoid ### XSS (Cross-Site Scripting) - [ ] Never `echo` user input without escaping - [ ] Never use `innerHTML` with user data - [ ] Always escape in HTML context ### SQL Injection - [ ] Never concatenate variables into SQL - [ ] Always use `$wpdb->prepare()` - [ ] Sanitize before `prepare()` ### CSRF (Cross-Site Request Forgery) - [ ] All forms have nonces - [ ] All AJAX has nonces - [ ] All admin actions have nonces ### Authorization Bypass - [ ] Never use `is_admin()` alone - [ ] Always check capabilities - [ ] Verify ownership for user-specific data ### Path Traversal - [ ] Validate file paths - [ ] Use `realpath()` to resolve paths - [ ] Never allow `../` in file operations --- ## Quick Security Scan Run this checklist on every file: 1. [ ] ABSPATH check at top 2. [ ] Unique prefix on all names 3. [ ] All `$_POST`/`$_GET` sanitized 4. [ ] All output escaped 5. [ ] All forms/AJAX have nonces 6. [ ] All actions check capabilities 7. [ ] All `$wpdb` queries use `prepare()` 8. [ ] Assets load conditionally 9. [ ] No direct file access 10. [ ] No hardcoded credentials --- ## Resources - **WordPress Security Whitepaper**: https://wordpress.org/about/security/ - **Plugin Security**: https://developer.wordpress.org/apis/security/ - **Patchstack Database**: https://patchstack.com/database/ - **Wordfence**: https://www.wordfence.com/blog/ - **WPScan**: https://wpscan.com/ --- **Last Updated**: 2025-11-06 **Status**: Production Ready