wp-security-review
WordPress security audit and vulnerability analysis. Use when reviewing WordPress code for security issues, auditing themes/plugins for vulnerabilities, checking authentication/authorization, analyzing input validation, or detecting security anti-patterns, or when user mentions "security review", "security audit", "vulnerability", "XSS", "SQL injection", "CSRF", "nonce", "sanitize", "escape", "validate", "authentication", "authorization", "permissions", "capabilities", "hacked", or "malware".
$ インストール
git clone https://github.com/vapvarun/claude-backup /tmp/claude-backup && cp -r /tmp/claude-backup/skills/wp-security-review ~/.claude/skills/claude-backup// tip: Run this command in your terminal to install the skill
name: wp-security-review description: WordPress security audit and vulnerability analysis. Use when reviewing WordPress code for security issues, auditing themes/plugins for vulnerabilities, checking authentication/authorization, analyzing input validation, or detecting security anti-patterns, or when user mentions "security review", "security audit", "vulnerability", "XSS", "SQL injection", "CSRF", "nonce", "sanitize", "escape", "validate", "authentication", "authorization", "permissions", "capabilities", "hacked", or "malware".
WordPress Security Review Skill
Overview
Systematic security code review for WordPress themes, plugins, and custom code. Core principle: Scan for critical vulnerabilities first (SQL injection, XSS, authentication bypass), then authorization issues, then hardening opportunities. Report with line numbers and severity levels.
When to Use
Use when:
- Reviewing PR/code for WordPress theme or plugin security
- User reports suspected hack, malware, or security breach
- Auditing before public release or security certification
- Checking authentication, authorization, or capability checks
- Investigating suspicious code or backdoors
Don't use for:
- Performance-only reviews (use wp-performance-review)
- General PHP code review not specific to WordPress
- Server/infrastructure security (focus is on code)
Code Review Workflow
- Identify file type and apply relevant checks below
- Scan for critical vulnerabilities first (SQLi, XSS, RCE, auth bypass)
- Check authorization issues (missing capability checks, IDOR)
- Note hardening opportunities (security headers, configuration)
- Report with line numbers using output format below
OWASP Top 10 WordPress Mapping
| OWASP Risk | WordPress Manifestation |
|---|---|
| A01 Broken Access Control | Missing current_user_can(), direct file access, IDOR |
| A02 Cryptographic Failures | Weak hashing, exposed secrets, insecure cookies |
| A03 Injection | SQL injection, XSS, command injection, LDAP injection |
| A04 Insecure Design | Logic flaws, race conditions, predictable tokens |
| A05 Security Misconfiguration | Debug enabled, directory listing, default credentials |
| A06 Vulnerable Components | Outdated plugins, known CVEs, abandoned libraries |
| A07 Auth Failures | Weak passwords, session fixation, brute force |
| A08 Data Integrity Failures | Insecure deserialization, missing integrity checks |
| A09 Logging Failures | Missing audit trails, excessive error exposure |
| A10 SSRF | Unvalidated URLs in wp_remote_get(), redirects |
File-Type Specific Checks
Plugin/Theme PHP Files (functions.php, plugin.php, *.php)
Scan for:
$_GET,$_POST,$_REQUESTwithout sanitization → CRITICAL: Input validation$wpdb->query()with string concatenation → CRITICAL: SQL injectionecho,printwithout escaping → CRITICAL: XSS vulnerability- Missing
wp_verify_nonce()in form handlers → CRITICAL: CSRF - Missing
current_user_can()before privileged actions → CRITICAL: Auth bypass eval(),assert(),create_function()→ CRITICAL: Code executionunserialize()with user input → CRITICAL: Object injectioninclude,requirewith user input → CRITICAL: LFI/RFI
Database Operations
Scan for:
$wpdb->prepare()not used with variables → CRITICAL: SQL injectionesc_sql()used instead ofprepare()→ WARNING: Prefer prepare()LIKEqueries without$wpdb->esc_like()→ WARNING: Wildcard injection- Direct table creation without
dbDelta()→ INFO: Schema management
AJAX & REST Handlers
Scan for:
wp_ajax_nopriv_*without rate limiting → WARNING: Abuse potential- Missing
permission_callbackin REST routes → CRITICAL: Auth bypass 'permission_callback' => '__return_true'→ WARNING: Public endpoint- Missing nonce in AJAX actions → CRITICAL: CSRF vulnerability
File Operations
Scan for:
file_get_contents(),file_put_contents()with user paths → CRITICAL: Path traversalmove_uploaded_file()without validation → CRITICAL: Arbitrary upload- Missing MIME type validation → WARNING: Upload bypass
unlink(),rmdir()with user input → CRITICAL: Arbitrary deletion
Authentication & Sessions
Scan for:
- Custom authentication instead of
wp_authenticate()→ WARNING: Security bypass wp_set_auth_cookie()without proper validation → CRITICAL: Auth bypass- Session handling outside WordPress → WARNING: Session fixation
- Plain text password storage → CRITICAL: Credential exposure
External Requests
Scan for:
wp_remote_get()with user-supplied URL → CRITICAL: SSRF- Missing URL validation before requests → WARNING: SSRF potential
allow_redirects => truewith external URLs → WARNING: Open redirect
Cron & Scheduled Tasks
Scan for:
- Cron hook name same as internal
do_action()in callback → CRITICAL: Infinite recursion (DoS) wp_schedule_event()withoutwp_next_scheduled()check → WARNING: Duplicate events- Missing
wp_clear_scheduled_hook()on deactivation → WARNING: Orphaned events - Long-running cron without
set_time_limit()→ WARNING: Timeout issues - Cron callbacks without try-catch → WARNING: Silent failures
Detection pattern for infinite recursion:
// Check if any cron hook name matches a do_action() call in its callback
// Example: wp_schedule_event( time(), 'hourly', 'my_hook' );
// add_action( 'my_hook', 'callback' );
// function callback() { do_action( 'my_hook' ); } // INFINITE LOOP!
Search Patterns for Quick Detection
# Critical: SQL Injection patterns
grep -rn '\$wpdb->query\s*(' . | grep -v 'prepare'
grep -rn '\$wpdb->get_' . | grep -v 'prepare'
grep -rn "esc_sql\s*(" .
# Critical: XSS patterns (unescaped output)
grep -rn 'echo\s*\$_' .
grep -rn 'print\s*\$_' .
grep -rn '<?=\s*\$' . | grep -v 'esc_'
# Critical: Missing nonce verification
grep -rn 'wp_ajax_' . | grep -l 'wp_ajax' | xargs grep -L 'wp_verify_nonce\|check_ajax_referer'
# Critical: Dangerous functions
grep -rn '\beval\s*(' .
grep -rn '\bassert\s*(' .
grep -rn 'create_function\s*(' .
grep -rn 'unserialize\s*(' .
grep -rn 'call_user_func.*\$_' .
# Critical: File inclusion vulnerabilities
grep -rn 'include.*\$_\|require.*\$_' .
grep -rn 'file_get_contents.*\$_' .
# Critical: Missing capability checks
grep -rn 'update_option\|delete_option' . | grep -v 'current_user_can'
grep -rn 'wp_delete_post\|wp_update_post' . | grep -v 'current_user_can'
# Warning: Unsafe input usage
grep -rn '\$_GET\[' . | grep -v 'sanitize_\|esc_\|intval\|absint'
grep -rn '\$_POST\[' . | grep -v 'sanitize_\|esc_\|intval\|absint'
# Warning: REST API without permissions
grep -rn 'register_rest_route' . | grep -v 'permission_callback'
grep -rn '__return_true.*permission_callback\|permission_callback.*__return_true' .
# Info: Debug/development code left in
grep -rn 'WP_DEBUG.*true' .
grep -rn 'error_reporting\|display_errors' .
grep -rn 'var_dump\|print_r\|debug_backtrace' .
Quick Reference: Security Anti-Patterns
SQL Injection
// ❌ CRITICAL: SQL injection via string concatenation.
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_title = '$title'"
);
// ❌ CRITICAL: SQL injection via sprintf (still vulnerable).
$results = $wpdb->get_results(
sprintf( "SELECT * FROM %s WHERE ID = %s", $wpdb->posts, $id )
);
// ✅ GOOD: Use $wpdb->prepare() for all variable data.
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title = %s",
$title
)
);
// ❌ WARNING: esc_sql() is not sufficient for complex queries.
$title = esc_sql( $_GET['title'] );
$wpdb->query( "SELECT * FROM wp_posts WHERE post_title = '$title'" );
// ✅ GOOD: Always use prepare(), even for "safe" looking queries.
$wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_author = %d",
$user_id
)
);
// ❌ WARNING: LIKE queries need esc_like() in addition to prepare().
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s", '%' . $search . '%' );
// ✅ GOOD: Use esc_like() for LIKE wildcards.
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
'%' . $wpdb->esc_like( $search ) . '%'
);
Cross-Site Scripting (XSS)
// ❌ CRITICAL: Unescaped output - XSS vulnerability.
echo $_GET['search'];
echo $user_input;
echo $post->post_title; // Even "safe" data should be escaped.
// ✅ GOOD: Always escape output based on context.
echo esc_html( $_GET['search'] ); // HTML context.
echo esc_attr( $value ); // HTML attributes.
echo esc_url( $url ); // URLs.
echo esc_js( $string ); // JavaScript strings.
echo wp_kses_post( $content ); // Allow safe HTML.
// ❌ CRITICAL: Unescaped in HTML attribute.
<input value="<?php echo $value; ?>">
// ✅ GOOD: Escape for attribute context.
<input value="<?php echo esc_attr( $value ); ?>">
// ❌ CRITICAL: Unescaped URL - JavaScript injection.
<a href="<?php echo $url; ?>">Link</a>
// ✅ GOOD: Validate and escape URLs.
<a href="<?php echo esc_url( $url ); ?>">Link</a>
// ❌ WARNING: wp_kses_post() in wrong context.
<input value="<?php echo wp_kses_post( $value ); ?>">
// ✅ GOOD: Use appropriate escaping for context.
<input value="<?php echo esc_attr( wp_strip_all_tags( $value ) ); ?>">
// ❌ CRITICAL: JSON output without escaping.
<script>var data = <?php echo json_encode( $data ); ?>;</script>
// ✅ GOOD: Use wp_json_encode() and proper escaping.
<script>var data = <?php echo wp_json_encode( $data ); ?>;</script>
Cross-Site Request Forgery (CSRF)
// ❌ CRITICAL: Form without nonce - CSRF vulnerable.
<form method="post" action="">
<input type="submit" value="Delete">
</form>
<?php
if ( isset( $_POST['submit'] ) ) {
delete_data();
}
// ✅ GOOD: Add nonce field and verify on submission.
<form method="post" action="">
<?php wp_nonce_field( 'delete_action', 'delete_nonce' ); ?>
<input type="submit" name="submit" value="Delete">
</form>
<?php
if ( isset( $_POST['submit'] ) ) {
if ( ! wp_verify_nonce( $_POST['delete_nonce'], 'delete_action' ) ) {
wp_die( 'Security check failed' );
}
delete_data();
}
// ❌ CRITICAL: AJAX handler without nonce verification.
add_action( 'wp_ajax_delete_item', 'handle_delete' );
function handle_delete() {
$id = intval( $_POST['id'] );
wp_delete_post( $id );
wp_die();
}
// ✅ GOOD: Verify nonce in AJAX handlers.
add_action( 'wp_ajax_delete_item', 'handle_delete' );
function handle_delete() {
check_ajax_referer( 'delete_item_nonce', 'security' );
if ( ! current_user_can( 'delete_posts' ) ) {
wp_send_json_error( 'Unauthorized' );
}
$id = intval( $_POST['id'] );
wp_delete_post( $id );
wp_send_json_success();
}
Authorization & Capability Checks
// ❌ CRITICAL: No capability check before privileged action.
function delete_all_posts() {
$wpdb->query( "TRUNCATE TABLE {$wpdb->posts}" );
}
// ✅ GOOD: Always verify capabilities.
function delete_all_posts() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized access' );
}
// ... proceed with action.
}
// ❌ CRITICAL: Checking wrong capability.
if ( current_user_can( 'read' ) ) {
update_option( 'critical_setting', $value );
}
// ✅ GOOD: Use appropriate capability for the action.
if ( current_user_can( 'manage_options' ) ) {
update_option( 'critical_setting', $value );
}
// ❌ CRITICAL: IDOR - no ownership verification.
function get_user_data() {
$user_id = intval( $_GET['user_id'] );
return get_user_meta( $user_id, 'private_data', true );
}
// ✅ GOOD: Verify the user owns the resource or has permission.
function get_user_data() {
$user_id = intval( $_GET['user_id'] );
if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_users' ) ) {
wp_die( 'Unauthorized access' );
}
return get_user_meta( $user_id, 'private_data', true );
}
// ❌ WARNING: REST endpoint with no permission check.
register_rest_route( 'myplugin/v1', '/data', array(
'methods' => 'GET',
'callback' => 'get_sensitive_data',
) );
// ✅ GOOD: Always define permission_callback.
register_rest_route( 'myplugin/v1', '/data', array(
'methods' => 'GET',
'callback' => 'get_sensitive_data',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
) );
Input Validation & Sanitization
// ❌ CRITICAL: Using raw input directly.
$email = $_POST['email'];
$name = $_GET['name'];
// ✅ GOOD: Sanitize all input based on expected type.
$email = sanitize_email( $_POST['email'] );
$name = sanitize_text_field( $_GET['name'] );
$html = wp_kses_post( $_POST['content'] );
$int = absint( $_GET['id'] );
$url = esc_url_raw( $_POST['website'] );
// ❌ WARNING: Sanitizing but not validating.
$email = sanitize_email( $_POST['email'] );
update_user_meta( $user_id, 'email', $email );
// ✅ GOOD: Validate after sanitizing.
$email = sanitize_email( $_POST['email'] );
if ( ! is_email( $email ) ) {
wp_die( 'Invalid email address' );
}
update_user_meta( $user_id, 'email', $email );
// ❌ CRITICAL: Trusting hidden form fields.
$user_role = $_POST['user_role']; // User can modify this!
// ✅ GOOD: Validate against allowed values.
$allowed_roles = array( 'subscriber', 'contributor' );
$user_role = sanitize_text_field( $_POST['user_role'] );
if ( ! in_array( $user_role, $allowed_roles, true ) ) {
wp_die( 'Invalid role' );
}
// ❌ WARNING: Using sanitize_text_field for file paths.
$file = sanitize_text_field( $_GET['file'] );
include $file;
// ✅ GOOD: Validate file paths against whitelist.
$allowed_files = array( 'header.php', 'footer.php' );
$file = basename( $_GET['file'] ); // Strip path traversal.
if ( ! in_array( $file, $allowed_files, true ) ) {
wp_die( 'Invalid file' );
}
include get_template_directory() . '/' . $file;
File Upload Security
// ❌ CRITICAL: No validation on file upload.
$target = wp_upload_dir()['path'] . '/' . $_FILES['file']['name'];
move_uploaded_file( $_FILES['file']['tmp_name'], $target );
// ✅ GOOD: Use WordPress upload handling with validation.
$allowed_types = array( 'image/jpeg', 'image/png', 'image/gif' );
// Verify MIME type.
$file_type = wp_check_filetype( $_FILES['file']['name'] );
if ( ! in_array( $file_type['type'], $allowed_types, true ) ) {
wp_die( 'Invalid file type' );
}
// Use WordPress media handling.
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$attachment_id = media_handle_upload( 'file', 0 );
if ( is_wp_error( $attachment_id ) ) {
wp_die( $attachment_id->get_error_message() );
}
// ❌ CRITICAL: Extension-only check (bypassable).
$ext = pathinfo( $_FILES['file']['name'], PATHINFO_EXTENSION );
if ( $ext === 'jpg' ) {
// Allows "malware.php.jpg" renamed to "malware.php".
}
// ✅ GOOD: Check both extension and MIME type.
$file_info = wp_check_filetype_and_ext(
$_FILES['file']['tmp_name'],
$_FILES['file']['name']
);
$allowed_mimes = array( 'jpg|jpeg|jpe' => 'image/jpeg' );
if ( ! in_array( $file_info['type'], $allowed_mimes, true ) ) {
wp_die( 'Invalid file' );
}
Dangerous Functions
// ❌ CRITICAL: Code execution via eval().
eval( $_POST['code'] );
// ❌ CRITICAL: Code execution via assert().
assert( $_GET['assertion'] );
// ❌ CRITICAL: Deprecated and dangerous.
create_function( '$a', $_POST['code'] );
// ❌ CRITICAL: Object injection via unserialize().
$data = unserialize( $_COOKIE['data'] );
// ✅ GOOD: Use JSON for data serialization.
$data = json_decode( $_COOKIE['data'], true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
$data = array();
}
// ❌ CRITICAL: Command injection.
system( 'ls ' . $_GET['dir'] );
exec( $_POST['command'] );
shell_exec( $user_input );
passthru( $command );
// ✅ GOOD: Avoid shell commands; if necessary, escape properly.
$safe_dir = escapeshellarg( $dir );
$output = shell_exec( "ls $safe_dir" );
// ❌ CRITICAL: Dynamic function calls with user input.
$func = $_GET['callback'];
$func(); // Arbitrary function execution!
call_user_func( $_POST['function'], $args );
// ✅ GOOD: Whitelist allowed functions.
$allowed_callbacks = array(
'format_date' => 'my_format_date',
'format_number' => 'my_format_number',
);
$callback_key = sanitize_key( $_GET['callback'] );
if ( isset( $allowed_callbacks[ $callback_key ] ) ) {
call_user_func( $allowed_callbacks[ $callback_key ], $args );
}
Server-Side Request Forgery (SSRF)
// ❌ CRITICAL: SSRF - user controls URL destination.
$url = $_GET['url'];
$response = wp_remote_get( $url );
// ✅ GOOD: Validate URL against whitelist.
$url = esc_url_raw( $_GET['url'] );
$allowed = array( 'api.example.com', 'cdn.example.com' );
$parsed_host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! in_array( $parsed_host, $allowed, true ) ) {
wp_die( 'URL not allowed' );
}
$response = wp_remote_get( $url, array(
'timeout' => 5,
'redirection' => 0, // Disable redirects to prevent bypass.
) );
// ❌ WARNING: Allowing redirects can bypass host validation.
wp_remote_get( $url, array( 'redirection' => 5 ) );
// ✅ GOOD: Disable redirects or re-validate after redirect.
wp_remote_get( $url, array( 'redirection' => 0 ) );
Information Disclosure
// ❌ WARNING: Exposing debug info in production.
if ( WP_DEBUG ) {
echo $wpdb->last_query;
echo $wpdb->last_error;
}
// ✅ GOOD: Log errors instead of displaying.
if ( WP_DEBUG ) {
error_log( $wpdb->last_error );
}
// ❌ WARNING: Exposing full paths.
wp_die( 'Error in ' . __FILE__ );
// ✅ GOOD: Generic error messages.
wp_die( 'An error occurred. Please try again.' );
// ❌ WARNING: Version exposure in headers/source.
<meta name="generator" content="WordPress <?php bloginfo( 'version' ); ?>">
// ✅ GOOD: Remove version information.
remove_action( 'wp_head', 'wp_generator' );
// ❌ WARNING: Exposing user enumeration.
// Accessible: /?author=1 redirects to /author/admin/
// ✅ GOOD: Block user enumeration.
add_action( 'template_redirect', function() {
if ( isset( $_GET['author'] ) && ! is_admin() ) {
wp_redirect( home_url(), 301 );
exit;
}
} );
Secure Cookies & Sessions
// ❌ WARNING: Cookie without security flags.
setcookie( 'user_pref', $value );
// ✅ GOOD: Set secure cookie flags.
setcookie(
'user_pref',
$value,
array(
'expires' => time() + DAY_IN_SECONDS,
'path' => COOKIEPATH,
'domain' => COOKIE_DOMAIN,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Strict',
)
);
// ❌ CRITICAL: Storing sensitive data in cookies.
setcookie( 'user_password', $password );
setcookie( 'api_key', $api_key );
// ✅ GOOD: Store sensitive data server-side, use session reference.
$session_token = wp_generate_password( 32, false );
set_transient( 'session_' . $session_token, $user_data, HOUR_IN_SECONDS );
setcookie( 'session_token', $session_token, /* secure flags */ );
Security Headers
// ✅ GOOD: Add security headers.
add_action( 'send_headers', function() {
// Prevent clickjacking.
header( 'X-Frame-Options: SAMEORIGIN' );
// Prevent MIME type sniffing.
header( 'X-Content-Type-Options: nosniff' );
// Enable XSS filter.
header( 'X-XSS-Protection: 1; mode=block' );
// Referrer policy.
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
// Content Security Policy (customize as needed).
// header( "Content-Security-Policy: default-src 'self'" );
} );
Severity Definitions
| Severity | Description |
|---|---|
| Critical | Direct exploitation possible (SQLi, XSS, RCE, auth bypass) |
| Warning | Requires specific conditions to exploit |
| Info | Security hardening opportunity |
Output Format
Structure findings as:
## Security Review: [filename/component]
### Critical Vulnerabilities
- **Line X**: [Issue] - [Vulnerability type] - [Fix]
### Warnings
- **Line X**: [Issue] - [Risk] - [Fix]
### Hardening Recommendations
- [Security improvements]
### Summary
- Total issues: X Critical, Y Warnings, Z Info
- Risk level: [Critical/High/Medium/Low]
- Requires immediate attention: [Yes/No]
Common Mistakes
| Mistake | Why It's Wrong | Fix |
|---|---|---|
Using esc_sql() for injection protection | Doesn't handle all cases | Use $wpdb->prepare() |
| Escaping input instead of output | Data may be used in multiple contexts | Sanitize input, escape output |
| Nonce in GET request URL | Nonces can be logged/cached | Use POST for sensitive actions |
| Capability check in view, not controller | Can be bypassed via direct request | Check in action handler |
Trusting is_admin() for security | Only checks context, not permissions | Use current_user_can() |
| Cron hook name = internal action name | Infinite recursion, fatal error, DoS | Use different names: my_cron vs my_do_cron |
| Not clearing cron on deactivation | Orphaned events continue running | Use wp_clear_scheduled_hook() |
| Checkbox not in sanitize callback | Setting won't save when unchecked | Explicitly set false for missing checkbox |
Deep-Dive References
Load these references based on the task:
| Task | Reference to Load |
|---|---|
| Reviewing code for vulnerabilities | references/vulnerabilities.md |
| Implementing authentication | references/authentication-guide.md |
| Securing file operations | references/file-security.md |
| Hardening configuration | references/hardening-checklist.md |
Repository
