| name | wp-security-review |
| description | WordPress security code review and vulnerability detection. Use when reviewing WordPress PHP code for security issues, auditing themes/plugins for vulnerabilities, checking code before launch, analyzing AJAX/REST handlers for exploits, detecting XSS, SQL injection, CSRF, or authorization bypass, or when user mentions "security review", "vulnerability", "XSS", "SQL injection", "CSRF", "nonce", "sanitization", "escaping", "capability check", "privilege escalation", "file upload security", "insecure code", "auth bypass", or "security audit". Detects vulnerabilities in input handling, output escaping, authorization, nonces, database queries, file uploads, and dangerous functions. |
WordPress Security Review Skill
Overview
Systematic security code review for WordPress themes, plugins, and custom code. Core principle: Three-pillar security model: (1) Sanitize input early, (2) Validate authorization (nonces + capabilities), (3) Escape output late. Scan critical issues first (SQL injection in public code, XSS on unescaped output, missing nonces on state-changing operations), then warnings, then info-level improvements. Report with line numbers, severity, CWE references, and BAD/GOOD code pairs.
When to Use
Use when:
- Security audit of WordPress theme or plugin
- Pre-launch security review
- Investigating reported vulnerability
- Reviewing form handlers, AJAX endpoints, or REST API routes
- Analyzing user input processing or output rendering
- Code review for exploitable patterns (XSS, SQL injection, CSRF, auth bypass)
Don't use for:
- Server hardening or infrastructure security (Apache/nginx config, firewall rules)
- Web Application Firewall (WAF) configuration
- SSL/TLS certificate management
- Performance-only audits (use wp-performance-review)
- General non-WordPress PHP security
Code Review Workflow
- Identify file type and apply relevant checks below
- Scan for CRITICAL patterns first (SQL injection in public code, XSS on unescaped output, missing nonces on state-changing operations, file upload without validation)
- Check WARNING patterns (authorization issues, admin-only XSS, incomplete validation)
- Note INFO improvements (defense-in-depth, security constants, hardening opportunities)
- Apply context-aware severity adjustment (admin-only code = lower severity, public-facing code = highest severity, WP-CLI/cron = nonces not needed, REST API = different auth model)
- Report with line numbers using output format below
File-Type Specific Checks
Plugin/Theme PHP Files (functions.php, plugin.php, *.php)
Scan for:
- Missing
defined( 'ABSPATH' ) || exit; at top → WARNING: Direct file access possible
eval( → CRITICAL: Code injection vector (CWE-95)
exec(, shell_exec(, system(, passthru( → CRITICAL: Command injection if user input present (CWE-78)
base64_decode( $_ → CRITICAL: Possible encoded payload execution
unserialize( $_ → CRITICAL: Object injection (CWE-502)
$_GET[, $_POST[, $_REQUEST[ without sanitize_* → WARNING: Unsanitized input (CWE-20)
include $_, require $_, include_once $_, require_once $_ → CRITICAL: Path traversal/inclusion (CWE-22)
- Raw
move_uploaded_file( → CRITICAL: Use wp_handle_upload() instead
Form Handlers (admin-post.php, admin_post_* hooks)
Scan for three-step pattern (ALL must be present):
- Nonce verification →
wp_verify_nonce( $_POST['nonce_field'], 'action_name' ) or check_admin_referer() → CRITICAL if missing (CWE-352)
- Capability check →
current_user_can( 'capability' ) → CRITICAL if missing (CWE-862)
- Input sanitization →
sanitize_text_field(), sanitize_email(), etc. → WARNING if missing (CWE-20)
Missing ANY step = vulnerability. State-changing operations MUST have all three.
AJAX Handlers (wp_ajax_*, wp_ajax_nopriv_* hooks)
Scan for:
check_ajax_referer( 'action_name', 'nonce_field' ) → CRITICAL if missing on state-changing operations (CWE-352)
current_user_can( 'capability' ) → CRITICAL if missing when capability required (CWE-862)
wp_ajax_nopriv_* without nonce → CRITICAL: Public endpoint with no CSRF protection
- Input from
$_POST without sanitize_* → WARNING: Unsanitized input (CWE-20)
wp_send_json() without escaping when echoing user input → WARNING: JSON injection
REST API Endpoints (register_rest_route)
Scan for:
- Missing
permission_callback → CRITICAL: WordPress 5.5+ requires this (CWE-862)
'permission_callback' => '__return_true' on write operations → CRITICAL: Allows unauthorized access
'permission_callback' => '__return_true' on public read endpoints → OK (not a vulnerability)
- Missing
current_user_can() in permission callback → WARNING: May allow privilege escalation (CWE-863)
- Input validation missing on
$request->get_param() → WARNING: Unsanitized input (CWE-20)
- REST authentication via
X-WP-Nonce header or application passwords → INFO: Verify client sends header
Database Code ($wpdb->query, $wpdb->get_results, etc.)
Scan for:
$wpdb->query( or $wpdb->get_results( with string concatenation or interpolation → CRITICAL: SQL injection (CWE-89)
- Missing
$wpdb->prepare() when user input present → CRITICAL: SQL injection (CWE-89)
$wpdb->prepare( "... LIKE '%{$term}%'" ) → WARNING: Should use $wpdb->esc_like() first
- Double-preparing (preparing already-prepared strings) → WARNING: Escapes escape characters
- Direct use of
{$wpdb->prefix} in queries → OK (not a vulnerability, standard pattern)
Safe pattern: $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d", $post_id ) )
Template/Output Files (*.php in theme, templates)
Scan for:
echo $ or print $ without escaping function → CRITICAL: XSS (CWE-79)
<input value="<?php echo $ → CRITICAL: Use esc_attr() (CWE-79)
<a href="<?php echo $ → CRITICAL: Use esc_url() (CWE-79)
<script> blocks with PHP variables → CRITICAL: Use esc_js() or wp_localize_script() (CWE-79)
- Rich HTML output without
wp_kses() or wp_kses_post() → WARNING: May allow unsafe tags
Safe pattern: Always escape at point of output, not at input or storage.
File Upload Handlers
Scan for:
move_uploaded_file( instead of wp_handle_upload() → CRITICAL: Bypasses WP security checks (CWE-434)
- Missing
current_user_can() check → CRITICAL: Unauthorized file upload (CWE-862)
- No MIME type validation → WARNING: May allow executable uploads
- No file size limit → INFO: Could enable DoS
- Missing
wp_check_filetype_and_ext() → WARNING: File type spoofing possible (CWE-434)
JavaScript Files (*.js, *.jsx)
Scan for:
fetch() or $.ajax() to REST API without X-WP-Nonce header → WARNING: CSRF vulnerability (CWE-352)
$.post( ajaxurl, ...) without nonce in data → WARNING: CSRF vulnerability (CWE-352)
innerHTML = userInput or dangerouslySetInnerHTML → CRITICAL: DOM-based XSS (CWE-79)
eval( → CRITICAL: Code injection vector (CWE-95)
Search Patterns for Quick Detection (SEC-20)
grep -rn "\$wpdb->query(" . | grep -v "prepare"
grep -rn "\$wpdb->get_results(" . | grep -v "prepare"
grep -rn "\$wpdb->get_var(" . | grep -v "prepare"
grep -rn "\$wpdb->get_row(" . | grep -v "prepare"
grep -rn "echo \$_" .
grep -rn "print \$_" .
grep -rn "<?php echo \$" . | grep -v "esc_"
grep -rn "eval(" .
grep -rn "exec(" .
grep -rn "shell_exec(" .
grep -rn "system(" .
grep -rn "passthru(" .
grep -rn "base64_decode(\$_" .
grep -rn "unserialize(\$_" .
grep -rn "move_uploaded_file(" .
grep -rn "register_rest_route" . | grep -v "permission_callback"
grep -rn "include \$_" .
grep -rn "require \$_" .
grep -rn "include_once \$_" .
grep -rn "require_once \$_" .
grep -rn "\$_GET\[" . | grep -v "sanitize_"
grep -rn "\$_POST\[" . | grep -v "sanitize_"
grep -rn "\$_REQUEST\[" . | grep -v "sanitize_"
grep -rn "wp_ajax_nopriv_" .
grep -rn "admin_post_" . | grep -v "current_user_can"
grep -rn "wp_ajax_" . | grep -v "current_user_can"
grep -rn "<?php" . | head -20 | grep -v "ABSPATH"
grep -n "DISALLOW_FILE_EDIT" wp-config.php
grep -n "FORCE_SSL_ADMIN" wp-config.php
grep -n "DISALLOW_UNFILTERED_HTML" wp-config.php
Platform Context
Different hosting environments provide varying security layers:
Managed WordPress Hosts (WP Engine, Pantheon, Pressable, WordPress VIP, etc.):
- Often have platform-level WAF and malware scanning
- May block certain dangerous functions (eval, exec) at platform level
- Check host documentation for additional security requirements
- Code should still be written securely for portability
WordPress VIP:
- Strict code review requirements
- Many dangerous functions prohibited
- Additional security helpers available (
wpcom_vip_* functions)
- See VIP documentation for required patterns
Self-Hosted / Standard Hosting:
- No platform-level security beyond what code implements
- More responsibility for secure coding practices
- Ensure all three pillars (sanitize, authorize, escape) are implemented
Quick Reference: Critical Security Patterns
XSS Prevention (Output Escaping)
<h1><?php echo $_GET['title']; ?></h1>
<input value="<?php echo $user_input; ?>">
<a href="<?php echo $url; ?>">Link</a>
<script>var data = "<?php echo $json; ?>";</script>
<h1><?php echo esc_html( $_GET['title'] ); ?></h1>
<input value="<?php echo esc_attr( $user_input ); ?>">
<a href="<?php echo esc_url( $url ); ?>">Link</a>
<script>var data = <?php echo wp_json_encode( $data ); ?>;</script>
$title = esc_html( $_POST['title'] );
update_post_meta( $post_id, 'title', $title );
$title = sanitize_text_field( $_POST['title'] );
update_post_meta( $post_id, 'title', $title );
echo '<h1>' . esc_html( get_post_meta( $post_id, 'title', true ) ) . '</h1>';
echo wp_kses_post( $content );
$allowed_tags = array(
'a' => array( 'href' => array(), 'title' => array() ),
'br' => array(),
'strong' => array(),
);
echo wp_kses( $user_content, $allowed_tags );
SQL Injection Prevention
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->users} WHERE ID = $user_id" );
$results = $wpdb->query( "UPDATE {$wpdb->posts} SET post_title = '$title'" );
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->users} WHERE ID = %d",
$user_id
) );
$wpdb->query( $wpdb->prepare(
"UPDATE {$wpdb->posts} SET post_title = %s WHERE ID = %d",
$title,
$post_id
) );
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_status = %s AND post_author = %d",
$status,
$author_id
) );
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%%{$term}%%'" );
$like_term = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
$like_term
) );
if ( current_user_can( 'manage_options' ) ) {
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name = 'temp_setting'" );
}
CSRF/Nonce Verification (Three-Step Pattern)
add_action( 'admin_post_save_settings', function() {
update_option( 'my_setting', $_POST['value'] );
} );
add_action( 'admin_post_save_settings', function() {
check_admin_referer( 'save_settings_action', 'settings_nonce' );
update_option( 'my_setting', $_POST['value'] );
} );
add_action( 'admin_post_save_settings', function() {
if ( ! isset( $_POST['settings_nonce'] ) ||
! wp_verify_nonce( $_POST['settings_nonce'], 'save_settings_action' ) ) {
wp_die( 'Invalid nonce' );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Insufficient permissions' );
}
$value = sanitize_text_field( $_POST['value'] );
update_option( 'my_setting', $value );
wp_redirect( admin_url( 'admin.php?page=settings&updated=true' ) );
exit;
} );
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'save_settings_action', 'settings_nonce' ); ?>
<input type="hidden" name="action" value="save_settings">
<input type="text" name="value" value="<?php echo esc_attr( $current_value ); ?>">
<?php submit_button(); ?>
</form>
add_action( 'wp_ajax_update_user_meta', function() {
check_ajax_referer( 'update_meta_nonce', 'nonce' );
if ( ! current_user_can( 'edit_users' ) ) {
wp_send_json_error( 'Insufficient permissions' );
}
$user_id = absint( $_POST['user_id'] );
$value = sanitize_text_field( $_POST['value'] );
update_user_meta( $user_id, 'custom_field', $value );
wp_send_json_success();
} );
jQuery.post( ajaxurl, {
action: 'update_user_meta',
nonce: myAjax.nonce, // From wp_localize_script()
user_id: 123,
value: 'new value'
} );
Authorization/Capability Checks
add_action( 'admin_post_delete_user', function() {
wp_delete_user( $_POST['user_id'] );
} );
add_action( 'admin_post_delete_user', function() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'Insufficient permissions' );
}
wp_delete_user( $_POST['user_id'] );
} );
add_action( 'admin_post_delete_user', function() {
if ( ! current_user_can( 'delete_users' ) ) {
wp_die( 'Insufficient permissions' );
}
check_admin_referer( 'delete_user_action', 'delete_nonce' );
wp_delete_user( absint( $_POST['user_id'] ) );
} );
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( 'You cannot edit this post' );
}
register_rest_route( 'myapp/v1', '/users/(?P<id>\d+)', array(
'methods' => 'DELETE',
'callback' => 'myapp_delete_user',
'permission_callback' => function( $request ) {
return current_user_can( 'delete_users' );
},
) );
register_rest_route( 'myapp/v1', '/posts', array(
'methods' => 'GET',
'callback' => 'myapp_get_posts',
'permission_callback' => '__return_true', // Public read is OK
) );
File Upload Security
if ( isset( $_FILES['upload'] ) ) {
move_uploaded_file( $_FILES['upload']['tmp_name'], '/uploads/' . $_FILES['upload']['name'] );
}
$file = wp_handle_upload( $_FILES['upload'], array( 'test_form' => false ) );
add_action( 'admin_post_upload_file', function() {
check_admin_referer( 'upload_file_action', 'upload_nonce' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( 'Insufficient permissions' );
}
if ( ! isset( $_FILES['upload'] ) || UPLOAD_ERR_OK !== $_FILES['upload']['error'] ) {
wp_die( 'File upload failed' );
}
require_once( ABSPATH . 'wp-admin/includes/file.php' );
$file = wp_handle_upload( $_FILES['upload'], array( 'test_form' => false ) );
if ( isset( $file['error'] ) ) {
wp_die( 'Upload error: ' . esc_html( $file['error'] ) );
}
$attachment_id = wp_insert_attachment( array(
'post_title' => sanitize_file_name( $_FILES['upload']['name'] ),
'post_mime_type' => $file['type'],
'post_status' => 'inherit',
), $file['file'] );
} );
add_filter( 'upload_mimes', function( $mimes ) {
unset( $mimes['exe'] );
unset( $mimes['php'] );
$mimes['svg'] = 'image/svg+xml';
return $mimes;
} );
$filetype = wp_check_filetype_and_ext( $_FILES['upload']['tmp_name'], $_FILES['upload']['name'] );
if ( ! $filetype['ext'] ) {
wp_die( 'Invalid file type' );
}
Object Injection Prevention
$data = unserialize( $_POST['data'] );
$data = unserialize( $_COOKIE['cart_data'] );
$data = json_decode( $_POST['data'], true );
if ( JSON_ERROR_NONE !== json_last_error() ) {
wp_die( 'Invalid JSON' );
}
$data = get_option( 'my_internal_setting' );
$meta = get_post_meta( $post_id, 'my_field', true );
Dangerous Functions
eval( $_POST['code'] );
exec( 'ls -la ' . $_GET['dir'] );
$output = shell_exec( 'grep ' . $_POST['search'] . ' file.txt' );
$allowed_dirs = array( 'uploads', 'cache' );
$dir = sanitize_key( $_GET['dir'] );
if ( in_array( $dir, $allowed_dirs, true ) ) {
exec( 'ls -la ' . escapeshellarg( $dir ), $output );
}
Direct File Access Prevention
<?php
<?php
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'ABSPATH' ) ) {
die( 'Direct access not permitted.' );
}
Input Sanitization
$title = $_POST['title'];
update_post_meta( $post_id, 'title', $title );
$title = sanitize_text_field( $_POST['title'] );
$email = sanitize_email( $_POST['email'] );
$url = sanitize_url( $_POST['url'] );
$filename = sanitize_file_name( $_POST['filename'] );
$key = sanitize_key( $_POST['key'] );
$int = absint( $_POST['count'] );
$float = floatval( $_POST['price'] );
$textarea = sanitize_textarea_field( $_POST['description'] );
$ids = array_map( 'absint', (array) $_POST['ids'] );
$allowed_types = array( 'post', 'page', 'product' );
$type = sanitize_key( $_POST['type'] );
if ( ! in_array( $type, $allowed_types, true ) ) {
wp_die( 'Invalid type' );
}
Severity Definitions (SEC-22)
| Severity | Description |
|---|
| CRITICAL | Exploitable without authentication OR leads to data breach, RCE, or privilege escalation. Examples: SQL injection on public endpoint, XSS on unescaped output, missing nonce on admin action, file upload without capability check, eval() with user input |
| WARNING | Exploitable with authentication OR requires specific conditions. Examples: XSS in admin-only code, missing capability check on logged-in action, SQL injection in admin context, incomplete input sanitization |
| INFO | Defense-in-depth improvement or hardening opportunity. Examples: missing ABSPATH check, security constants not set in wp-config.php, missing security headers |
Context adjustments:
- Admin-only code: Lower severity by one level (CRITICAL → WARNING)
- WP-CLI/cron context: Nonces not required (no CSRF risk)
- REST API: Different auth model (permission_callback, not nonces)
- Public-facing code: Highest severity (no authentication barrier)
Output Format (SEC-23)
Group findings by FILE and severity level:
## Security Review: [filename]
### CRITICAL Issues
**Line X** | CWE-79 | XSS via unescaped output
- **Issue**: User input echoed directly without escaping
- **Risk**: Allows attackers to inject malicious JavaScript
- **Context**: Public-facing template (HIGH severity)
❌ **BAD:**
```php
<h1><?php echo $_GET['title']; ?></h1>
✅ GOOD:
<h1><?php echo esc_html( $_GET['title'] ); ?></h1>
WARNING Issues
Line Y | CWE-352 | Missing nonce verification
- Issue: Form handler missing wp_verify_nonce()
- Risk: State can be changed via CSRF attack
- Context: Admin-only (reduced severity)
[BAD/GOOD code pair]
INFO Recommendations
Line Z | Defense-in-depth | Missing ABSPATH check
- Add
defined( 'ABSPATH' ) || exit; at top of file
Summary
- Total: 1 CRITICAL, 1 WARNING, 1 INFO
- Risk level: HIGH (exploitable CRITICAL issue present)
- Immediate action: Fix line X (unescaped output)
## Common Mistakes (SEC-24)
When performing security reviews, avoid these false positives:
| Mistake | Why It's Wrong | Context |
|---------|----------------|---------|
| Flagging missing nonces in WP-CLI commands | WP-CLI runs server-side, no CSRF risk | Nonces only needed for HTTP requests |
| Flagging `wp_kses_post()` as insufficient escaping | `wp_kses_post()` is valid for trusted content with safe HTML | Allows `<p>`, `<a>`, `<strong>`, etc. - appropriate for post content |
| Flagging admin-only `$wpdb->query()` with hardcoded SQL | No user input = no injection risk | Review for user input presence, not just prepare() usage |
| Flagging `current_user_can()` inside REST `permission_callback` as redundant | This is the CORRECT location for capability checks in REST API | REST API auth model, not redundant |
| Flagging `esc_html( get_the_title() )` as double-escaping | WP core sanitizes on storage, but late escaping is still best practice | Not technically double-escaping, WP stores raw data |
| Flagging `update_option()` in admin context as vulnerability | Options API is safe for admin use | Check capability first, then update_option() is fine |
| Flagging REST API with `__return_true` permission on GET endpoints | Public read access is often intentional | Only flag on write operations (POST, PUT, DELETE) |
| Flagging `sanitize_callback` in `register_setting()` as missing sanitization | Settings API handles sanitization via callback | This is the correct pattern for Settings API |
## Security Constants Check (SEC-19)
Review `wp-config.php` for security hardening constants:
```php
// ✅ GOOD: Disable file editor (INFO-level recommendation)
define( 'DISALLOW_FILE_EDIT', true );
// ✅ GOOD: Force SSL for admin (INFO if SSL available)
define( 'FORCE_SSL_ADMIN', true );
// ✅ GOOD: Disable unfiltered HTML for all users including admins
define( 'DISALLOW_UNFILTERED_HTML', true );
// ✅ GOOD: Disable plugin/theme installation (high-security environments)
define( 'DISALLOW_FILE_MODS', true );
// ✅ GOOD: Custom authentication keys (CRITICAL if using defaults)
define( 'AUTH_KEY', 'put-unique-phrase-here' ); // Use https://api.wordpress.org/secret-key/1.1/salt/
define( 'SECURE_AUTH_KEY', 'put-unique-phrase-here' );
define( 'LOGGED_IN_KEY', 'put-unique-phrase-here' );
define( 'NONCE_KEY', 'put-unique-phrase-here' );
define( 'AUTH_SALT', 'put-unique-phrase-here' );
define( 'SECURE_AUTH_SALT', 'put-unique-phrase-here' );
define( 'LOGGED_IN_SALT', 'put-unique-phrase-here' );
define( 'NONCE_SALT', 'put-unique-phrase-here' );
Deep-Dive References
Load these references for comprehensive vulnerability patterns and examples:
| Task | Reference to Load |
|---|
| Comprehensive vulnerability catalog with CWE mappings | references/vulnerability-patterns.md |
| Output escaping patterns and context-specific functions | references/escaping-guide.md |
| Input sanitization patterns by data type | references/sanitization-guide.md |
| Authorization and capability check patterns | references/auth-patterns.md |
| Nonce generation, verification, and CSRF prevention | references/nonce-csrf-guide.md |
Note: For standard security reviews, this SKILL.md contains all patterns needed. Load references when you need comprehensive CWE catalogs, edge cases, or platform-specific security requirements (WordPress VIP, etc.).