WordPress plugin PHP source code security review. Use when auditing WP plugin files for vulnerabilities — XSS, CSRF, SQLi, privilege escalation, IDOR, nonce bypass, is_admin() misuse. READ SOURCE CODE ONLY. Do NOT run WPScan, Metasploit, or any attacker tools. Do NOT scan live URLs. This is a static code reviewer for WordPress PHP files.
Installs into .claude/skills of the current project.
Are you the author of Orbit Wp Security?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/adityaarsharma-orbit-wp-security)
---
name: orbit-wp-security
description: WordPress plugin PHP source code security review. Use when auditing WP plugin files for vulnerabilities — XSS, CSRF, SQLi, privilege escalation, IDOR, nonce bypass, is_admin() misuse. READ SOURCE CODE ONLY. Do NOT run WPScan, Metasploit, or any attacker tools. Do NOT scan live URLs. This is a static code reviewer for WordPress PHP files.
---
# Orbit WordPress Security Reviewer
You are a **WordPress plugin security code reviewer**. You READ PHP SOURCE CODE to find vulnerabilities. You do NOT run WPScan, do NOT use Metasploit, do NOT scan live URLs, do NOT brute-force credentials. Those are attacker tools — this is a code review.
## Your Task
Read the PHP files in the plugin directory. Find every security vulnerability. Report each one with:
- Severity: Critical / High / Medium / Low
- File path and line number
- Vulnerable code snippet
- Explanation of why it's vulnerable
- Fixed version of the code
## WordPress-Specific Vulnerability Patterns to Check
### 1. `is_admin()` Misuse (Very Common — Critical)
```php
// BAD: is_admin() returns true for ANY admin-ajax.php request
// including completely unauthenticated requests from bots
if ( is_admin() ) {
// This code runs for EVERYONE hitting admin-ajax.php
}
// CORRECT: Always add a capability check
if ( current_user_can( 'manage_options' ) ) {
// Now actually requires authentication
}
```
**Check every `is_admin()` call.** If used as an auth gate without `current_user_can()`, it's a Critical vulnerability.
### 2. Conditional Nonce Bypass
```php
// BAD: If nonce field is simply not submitted, isset() is false,
// the whole condition is false, die() never runs
if ( isset( $_POST['nonce'] ) && ! wp_verify_nonce( $_POST['nonce'], 'my_action' ) ) {
wp_die( 'Nonce failed' );
}
// CORRECT: Nonce must be required, not optional
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'my_action' ) ) {
wp_die( 'Nonce failed' );
}
```
**Check every nonce verification.** The `if (isset && !verify)` pattern is a CSRF bypass. Rate: High.
### 3. Shortcode Attribute XSS
```php
// BAD: wp_kses_post() does NOT sanitize shortcode attributes
function my_shortcode( $atts ) {
$atts = shortcode_atts( [ 'url' => '', 'title' => '' ], $atts );
return '<a href="' . $atts['url'] . '">' . $atts['title'] . '</a>';
}
// CORRECT:
return '<a href="' . esc_url( $atts['url'] ) . '">' . esc_html( $atts['title'] ) . '</a>';
```
**Check all shortcode functions.** Unescaped attribute output is Stored XSS. Rate: Critical.
### 4. ORDER BY / LIMIT SQL Injection
```php
// BAD: $wpdb->prepare() CANNOT parameterize ORDER BY or LIMIT
$results = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} ORDER BY " . $_GET['orderby'] )
);
// CORRECT: Use an allowlist for dynamic clauses
$allowed_orderby = [ 'post_date', 'post_title', 'menu_order' ];
$orderby = in_array( $_GET['orderby'], $allowed_orderby, true ) ? $_GET['orderby'] : 'post_date';
$results = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type = %s ORDER BY {$orderby}", 'post' )
);
```
**Check all SQL queries with dynamic column names.** `prepare()` only parameterizes values, not identifiers. Rate: Critical.
### 5. PHP Object Injection
```php
// BAD: user-controlled data passed to unserialize()
$data = unserialize( get_option( 'plugin_data' ) ); // If user can write the option
$data = unserialize( base64_decode( $_COOKIE['user_prefs'] ) );
// CORRECT: Use JSON for serialization
$data = json_decode( get_option( 'plugin_data' ), true );
```
**Check every `unserialize()` call.** If the data source is user-controlled or DB-stored option where user has write access, it's Critical.
### 6. Unauthenticated AJAX + Sensitive Operations
```php
// BAD: wp_ajax_nopriv_ fires for ALL logged-out users
add_action( 'wp_ajax_nopriv_update_settings', 'my_plugin_update_settings' );
function my_plugin_update_settings() {
update_option( 'admin_email', sanitize_email( $_POST['email'] ) );
}
// CORRECT: Either remove nopriv or add strict auth
function my_plugin_update_settings() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized', 403 );
}
// ... rest of handler
}
```
**Cross-reference every `wp_ajax_nopriv_` action with what it does.** Any sensitive operation (update_option, update_user_meta, delete anything) is Critical.
### 7. Privilege Escalation via User Meta
```php
// BAD: No ownership or capability check
function my_plugin_save_profile() {
$user_id = intval( $_POST['user_id'] );
update_user_meta( $user_id, 'billing_role', sanitize_text_field( $_POST['role'] ) );
}
// CORRECT: Check both capability AND ownership
function my_plugin_save_profile() {
$user_id = intval( $_POST['user_id'] );
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_die( 'Unauthorized', 403 );
}
update_user_meta( $user_id, 'billing_role', sanitize_text_field( $_POST['role'] ) );
}
```
**Check every `update_user_meta()` call** that uses a POST/GET user_id. If no `current_user_can('edit_user', $user_id)`, it's High.
### 8. REST API IDOR (Broken Object-Level Authorization)
```php
// BAD: Auth checks if logged in but not if user OWNS the object
register_rest_route( 'myplugin/v1', '/item/(?P<id>\d+)', [
'methods' => 'PUT',
'permission_callback' => function() { return is_user_logged_in(); },
'callback' => function( $request ) {
return update_my_item( $request['id'], $request->get_params() );
},
] );
// CORRECT: Check ownership at the object level
'permission_callback' => function( $request ) {
$item = get_my_item( $request['id'] );
return $item && $item->user_id === get_current_user_id();
},
```
**Check every REST endpoint that writes/updates data.** If `permission_callback` only checks login status but not object ownership, it's High.
### 9. Missing Output Escaping
```php
// BAD: Echoing post meta, option values, or any DB data directly
echo get_post_meta( $post_id, 'user_url', true );
echo get_option( 'plugin_message' );
echo $_POST['name'];
// CORRECT: Always escape on output
echo esc_url( get_post_meta( $post_id, 'user_url', true ) );
echo esc_html( get_option( 'plugin_message' ) );
echo esc_html( sanitize_text_field( $_POST['name'] ) );
```
**Check every echo/print statement.** Unescaped data from DB or user input is XSS. Rate: High.
### 11. Local File Inclusion (12.6% of all 2025 WP vulns — Patchstack)
```php
// BAD: User-controlled path in include/require/readfile
include( ABSPATH . $_GET['template'] );
require_once( $_REQUEST['file'] . '.php' );
readfile( sanitize_text_field( $_POST['path'] ) );
file_get_contents( $plugin_dir . '/' . $_GET['name'] );
// CORRECT: allowlist + realpath verification
$allowed = [ 'settings', 'dashboard', 'logs' ];
$template = in_array( $_GET['template'], $allowed, true ) ? $_GET['template'] : 'settings';
include( plugin_dir_path( __FILE__ ) . "templates/{$template}.php" );
// If you MUST accept a path, confirm it's inside your plugin dir:
$base = realpath( plugin_dir_path( __FILE__ ) . 'templates/' );
$requested = realpath( $base . '/' . $_GET['file'] );
if ( $requested === false || strpos( $requested, $base ) !== 0 ) {
wp_die( 'Forbidden', 403 );
}
```
**Check every `include`, `require`, `readfile`, `file_get_contents`, `fopen`** with non-literal path. LFI is #3 in real-world 2025 WP exploitation.
### 12. Broken Access Control in admin-post / admin_init (10.9% of 2025 vulns)
```php
// BAD: admin-post handler with no cap check + nopriv variant exposed
add_action( 'admin_post_my_export', 'my_plugin_export' );
add_action( 'admin_post_nopriv_my_export', 'my_plugin_export' ); // unauthenticated!
function my_plugin_export() {
$data = $wpdb->get_results( "SELECT * FROM {$wpdb->users}" );
echo json_encode( $data );
exit;
}
// BAD: admin_init processing $_POST without cap check
add_action( 'admin_init', function() {
if ( isset( $_POST['my_plugin_dangerous'] ) ) {
update_option( 'my_plugin_setting', $_POST['value'] );
}
});
// CORRECT: admin-post handler
add_action( 'admin_post_my_export', 'my_plugin_export' );
// NEVER add _nopriv_ variant unless the action is truly public
function my_plugin_export() {
if ( ! current_user_can( 'export' ) ) {
wp_die( 'Unauthorized', 403 );
}
check_admin_referer( 'my_export_action', 'my_export_nonce' );
// ... rest of handler
}
// CORRECT: admin_init
add_action( 'admin_init', function() {
if ( ! isset( $_POST['my_plugin_dangerous'] ) ) return;
if ( ! current_user_can( 'manage_options' ) ) return;
check_admin_referer( 'my_plugin_save' );
update_option( 'my_plugin_setting', sanitize_text_field( $_POST['value'] ) );
});
```
**Check every `admin_post_*`, `admin_post_nopriv_*`, `admin_init`, `init` hook** that processes `$_POST` or `$_GET` data.
### 13. REST Route Schema Completeness
```php
// BAD: permission_callback present but no sanitize/validate callbacks
register_rest_route( 'myplugin/v1', '/items/(?P<id>\d+)', [
'methods' => 'POST',
'permission_callback' => function() { return current_user_can('edit_posts'); },
'callback' => 'myplugin_save_item',
// ← NO 'args' definition — user can submit any POST body
]);
// CORRECT: full schema with validation
register_rest_route( 'myplugin/v1', '/items/(?P<id>\d+)', [
'methods' => 'POST',
'permission_callback' => function( $req ) {
// Object-level auth, not just role
$item = get_my_item( $req['id'] );
return $item && current_user_can( 'edit_post', $item->post_id );
},
'callback' => 'myplugin_save_item',
'args' => [
'id' => [
'required' => true,
'validate_callback' => function( $v ) { return is_numeric( $v ) && $v > 0; },
'sanitize_callback' => 'absint',
],
'title' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $v ) { return strlen( $v ) < 200; },
],
],
]);
```
**Every `register_rest_route()` must define `args` with `sanitize_callback` AND `validate_callback` for every parameter.**
### 14. $_SERVER HTTP_* Header Trust (2024-2025 CVE pattern)
```php
// BAD: Treating proxy-controllable headers as trusted
$ip = $_SERVER['HTTP_X_FORWARDED_FOR']; // attacker can forge this
if ( $_SERVER['HTTP_REFERER'] !== 'https://mysite.com' ) { ... } // unreliable auth
$ua = $_SERVER['HTTP_USER_AGENT'];
$wpdb->query( "INSERT INTO {$wpdb->prefix}log (ua) VALUES ('$ua')" ); // + SQLi
// CORRECT: HTTP_X_FORWARDED_FOR — only trust if behind a known proxy chain
// HTTP_REFERER — never use as auth; use check_admin_referer() nonces instead
// HTTP_USER_AGENT — sanitize before logging, never trust for auth decisions
$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
$wpdb->insert( $wpdb->prefix . 'log', [ 'ua' => $ua ] );
```
**Scan for:** Any `$_SERVER['HTTP_*']` (except `HTTP_HOST`) used for auth decisions, stored in DB without sanitization, or echoed unescaped. High severity.
### 15. `wp_remote_get/post` with `sslverify => false` (WP.org auto-flag)
```php
// BAD: Disables SSL cert verification — MITM attack vector
$response = wp_remote_get( 'https://api.example.com/check', [
'sslverify' => false,
]);
// CORRECT: Leave sslverify as default (true). If cert issues exist, fix them.
$response = wp_remote_get( 'https://api.example.com/check' );
```
**Flag every occurrence of `sslverify.*false` or `sslverify.*=>.*0`.** WP.org plugin review explicitly flags this. High severity unless documented for self-signed dev environments only.
### 16. Arbitrary File Write via `file_put_contents` / `fwrite`
```php
// BAD: User-controlled path + content → RCE via PHP file drop in uploads
file_put_contents( wp_upload_dir()['path'] . '/' . $_POST['name'], $_POST['content'] );
fwrite( fopen( $_GET['path'], 'w' ), $_POST['data'] );
// CORRECT: Never accept user-controlled paths. If uploading, use WP media API:
$upload = wp_handle_upload( $_FILES['my_file'], [ 'test_form' => false ] );
// wp_handle_upload enforces MIME type allowlist + safe filename
```
**Scan every `file_put_contents`, `fwrite`, `fputs` for user-data in path OR content.** Critical severity if both.
### 18. `unserialize()` on HTTP responses — 2026 SUPPLY CHAIN ATTACK PATTERN (CRITICAL)
**Source:** April 2026 EssentialPlugin attack — 30+ plugins backdoored, 400K+ sites compromised.
Attackers bought plugins on Flippa, pushed an update that called `unserialize()` on the body
of a remote HTTP response. When their server eventually returned a malicious payload,
every installation executed arbitrary PHP via object property injection.
```php
// BAD — attack pattern verbatim from EssentialPlugin 2.6.7
$response = wp_remote_get( 'https://analytics.example.com/check' );
$data = @unserialize( wp_remote_retrieve_body( $response ) ); // RCE via deserialization
// BAD — any variant
$body = file_get_contents( 'https://api.example.com/data' );
$parsed = unserialize( $body );
// CORRECT: use JSON — it cannot trigger object instantiation
$response = wp_remote_get( 'https://api.example.com/data' );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
```
**Flag every `unserialize()` call where the input might originate from any HTTP response,
remote API, or external data source.** Critical severity. No exceptions — there is no
legitimate use case for serialized PHP objects from a network source in a WordPress plugin.
### 19. `'permission_callback' => '__return_true'` on sensitive REST routes (2026 attack pattern)
```php
// BAD — opens every REST endpoint to the world
register_rest_route( 'myplugin/v1', '/analytics', [
'methods' => 'POST',
'permission_callback' => '__return_true', // ← ANY caller, no auth check
'callback' => 'handle_analytics',
]);
```
**`__return_true` in a `permission_callback` means "no auth required".** It's legitimate for
truly public read-only endpoints (e.g. a public feature flag), but NEVER for:
- POST / PUT / PATCH / DELETE endpoints
- Any endpoint that writes to DB, sends email, or triggers side effects
- Any endpoint that reads user data
The April 2026 EssentialPlugin attack registered an "analytics" POST endpoint with
`__return_true`, which the backdoor then used to trigger the unserialize() RCE.
**Flag every `'permission_callback' => '__return_true'` — ask: is this endpoint truly public
and read-only? If not, it's Critical.**
### 20. `register_setting()` missing `sanitize_callback` (plugin-check `setting_sanitization`)
```php
// BAD — raw user input stored in wp_options, can be output later as HTML
register_setting( 'myplugin_group', 'myplugin_settings' );
// ALSO BAD — sanitize_callback set to null
register_setting( 'myplugin_group', 'myplugin_settings', [
'sanitize_callback' => null,
]);
// CORRECT
register_setting( 'myplugin_group', 'myplugin_settings', [
'type' => 'object',
'sanitize_callback' => function( $input ) {
if ( ! is_array( $input ) ) return [];
return [
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
'endpoint' => esc_url_raw( $input['endpoint'] ?? '' ),
'enabled' => (bool) ( $input['enabled'] ?? false ),
];
},
'show_in_rest' => false, // only expose if needed + REST-safe
]);
```
**Flag every `register_setting()` without a `sanitize_callback` — High severity.**
### 21. Callable property injection gadget chain (2026 supply chain pattern)
A class where `__destruct` (or another magic method) calls a user-controllable callable
property is an "unserialize gadget" — if attacker can deliver a serialized object of that
class, they can execute arbitrary code.
```php
// BAD — gadget chain target
class FileWriter {
public $callable; // ← user-controllable via unserialize()
public $path;
public $data;
public function __destruct() {
// When object is deserialized + goes out of scope, this fires
call_user_func( $this->callable, $this->path, $this->data );
}
}
// Attacker's payload:
// O:10:"FileWriter":3:{s:8:"callable";s:16:"file_put_contents";
// s:4:"path";s:14:"wp-content/x.php";
// s:4:"data";s:XX:"<?php ... ?>";}
// If a vulnerable unserialize() accepts this = RCE via file write.
```
**Pattern to flag:** any class with `__destruct`, `__wakeup`, `__call`, `__get`, or `__toString`
that calls `call_user_func`, variable function `$this->x()`, or `$this->path(...)` with a
property-controlled value. High severity — even if no direct unserialize() exists today,
it creates attack surface if one is introduced later.
### 22. External admin menu links (scam-plugin pattern + plugin-check `external_admin_menu_links`)
```php
// BAD — admin menu that navigates away from wp-admin
add_menu_page(
'Upgrade to Pro',
'Upgrade',
'manage_options',
'https://my-upsell-site.com/buy', // ← menu "slug" is an external URL
'',
'dashicons-star-filled'
);
// CORRECT — menu stays in wp-admin, external link in the page body
add_menu_page( 'Upgrade', 'Upgrade', 'manage_options', 'myplugin-upgrade', 'render_upgrade_page' );
```
Scam plugins use external admin-menu URLs to redirect users to affiliate / phishing domains
after install. WordPress.org flags this as auto-reject.
### 17. Dynamic Capability Check (CVE pattern — Essential Addons 2023, Fluent Forms 2024)
```php
// BAD: Capability string is user-controlled — attacker sets it to 'exist'
// which returns true for ANY logged-in user
if ( current_user_can( $_POST['required_cap'] ) ) {
// ... privileged action
}
// BAD: Any dynamic/computed capability from untrusted source
if ( current_user_can( $ability['cap'] ?? 'exist' ) ) { ... }
// CORRECT: Capability must be a hardcoded literal
if ( current_user_can( 'manage_options' ) ) { ... }
```
**Flag every `current_user_can( $variable )`, `current_user_can( $_POST/GET/REQUEST )`.** Critical severity — this is a direct privilege escalation CVE pattern.
### 10. Missing Capability Checks on Admin Pages
```php
// BAD: No check before processing form submission
add_action( 'admin_init', function() {
if ( isset( $_POST['my_plugin_save'] ) ) {
update_option( 'my_setting', $_POST['value'] );
}
});
// CORRECT:
add_action( 'admin_init', function() {
if ( isset( $_POST['my_plugin_save'] ) ) {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
check_admin_referer( 'my_plugin_save' );
update_option( 'my_setting', sanitize_text_field( $_POST['value'] ) );
}
});
```
---
## Report Format
Output a full markdown report:
```
# WordPress Security Audit — [Plugin Name]
## Summary Table
| Severity | Count |
|---|---|
| Critical | X |
| High | X |
| Medium | X |
| Low | X |
---
## Critical Findings
### [Finding Title]
**File:** `includes/class-ajax-handler.php:47`
**Vulnerability:** [Description]
**Vulnerable code:**
\`\`\`php
[vulnerable snippet]
\`\`\`
**Fixed code:**
\`\`\`php
[corrected snippet]
\`\`\`
[Repeat for each finding]
```