wp-security-deep

Deep security audit for WordPress plugin/theme PHP code, covering issues beyond the basic sanitize/escape/nonce checklist — PHP object injection (unserialize), SSRF in remote requests, CSRF on state-changing GET handlers, mass assignment via $_POST loops, insecure file include / template injection, mail header injection, ZipSlip in archive extraction, type-juggling in auth comparisons, and TOCTOU race patterns in option/meta locks. Use after or alongside wp-security-audit when reviewing complex plugins, REST APIs, integrations that fetch remote URLs, file processors, or any code that handles uploads, archives, or self-rolled auth tokens.

Best use case

wp-security-deep is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Deep security audit for WordPress plugin/theme PHP code, covering issues beyond the basic sanitize/escape/nonce checklist — PHP object injection (unserialize), SSRF in remote requests, CSRF on state-changing GET handlers, mass assignment via $_POST loops, insecure file include / template injection, mail header injection, ZipSlip in archive extraction, type-juggling in auth comparisons, and TOCTOU race patterns in option/meta locks. Use after or alongside wp-security-audit when reviewing complex plugins, REST APIs, integrations that fetch remote URLs, file processors, or any code that handles uploads, archives, or self-rolled auth tokens.

Teams using wp-security-deep should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/wp-security-deep/SKILL.md --create-dirs "https://raw.githubusercontent.com/nvdigitalsolutions/mcp-ai-wpoos/main/.agents/skills/wp-security-deep/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/wp-security-deep/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How wp-security-deep Compares

Feature / Agentwp-security-deepStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Deep security audit for WordPress plugin/theme PHP code, covering issues beyond the basic sanitize/escape/nonce checklist — PHP object injection (unserialize), SSRF in remote requests, CSRF on state-changing GET handlers, mass assignment via $_POST loops, insecure file include / template injection, mail header injection, ZipSlip in archive extraction, type-juggling in auth comparisons, and TOCTOU race patterns in option/meta locks. Use after or alongside wp-security-audit when reviewing complex plugins, REST APIs, integrations that fetch remote URLs, file processors, or any code that handles uploads, archives, or self-rolled auth tokens.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# WordPress security audit — deep checks

Run AFTER `wp-security-audit` covers the basics (sanitize, escape,
nonce, capability, SQL prepare, AJAX nopriv, REST permission). This
skill catches the second-tier issues that static-analysis tools miss
and that a hurried review skips.

## When to use this skill

Trigger when the basic audit is clean but the code does any of:

- Calls `unserialize`, `maybe_unserialize` on stored or transmitted data.
- Calls `wp_remote_*`, `file_get_contents`, `curl_*`, `fopen` with a
  URL that could be influenced by input.
- Has admin pages or AJAX/REST handlers driven by `?action=` GET params
  that perform writes.
- Loops `$_POST` / `$_REQUEST` keys into `update_post_meta`,
  `update_user_meta`, `wp_update_user`, `wp_insert_post`, or similar.
- Includes/requires a path that contains any input-derived component.
- Sends mail with user-controlled `From`, `Cc`, `Bcc`, or custom headers.
- Extracts uploaded archives (`ZipArchive`, `PharData`, `tar`).
- Compares tokens, hashes, or secrets with `==` / `===` instead of
  `hash_equals`.
- Implements its own lock / counter via `get_option` + `update_option`.

## Audit checks

### 1. PHP object injection via unserialize

```php
// HIGH — attacker-controlled serialized payload → gadget chains
$data = unserialize( $_POST['payload'] );
```

Even with `[ 'allowed_classes' => false ]`, prefer `json_decode` for
network/user input. `maybe_unserialize` on `get_option` is generally
safe (admin wrote it), but flag it on user-meta keys that any user
can write (custom registration forms, profile editors).

`Phar` deserialization: on **PHP < 8.0**, filesystem functions
(`file_exists`, `is_dir`, `filesize`, `fopen`, etc.) on a path using
the `phar://` stream wrapper would auto-unserialize the archive's
metadata, enabling object-injection gadget chains. PHP 8.0+ removed
this auto-unserialization (RFC: phar_stop_autoloading_metadata) — the
risk now requires an explicit `Phar::getMetadata()` call. So the
finding severity depends on the deployment's minimum PHP version:

- PHP 7.x supported: HIGH if any filesystem function is called with
  user-influenced paths. Strip `phar://` from input.
- PHP 8.0+ only: still flag explicit `Phar::getMetadata()` over
  user-controlled archives, plus any `unserialize()` of binary blobs.

**Fix:** JSON for transport, allowlist classes if unserialize is
unavoidable, never accept `phar://` from input on PHP 7.x.

### 2. SSRF in outbound requests

```php
// HIGH — internal network probe / cloud metadata exfil
$response = wp_remote_get( $_POST['webhook_url'] );
```

**Preferred defense — host allowlist.** If the integration only ever
talks to a known set of hosts (Stripe, Slack, an internal API),
allowlist them:

```php
$url   = esc_url_raw( wp_unslash( $_POST['webhook_url'] ?? '' ) );
$parts = wp_parse_url( $url );

$allowed_hosts = [ 'api.stripe.com', 'hooks.slack.com' ];
if ( empty( $parts['host'] )
     || ! in_array( strtolower( $parts['host'] ), $allowed_hosts, true )
     || ! in_array( $parts['scheme'] ?? '', [ 'https' ], true ) ) {
    wp_die( 'Forbidden host', 403 );
}

$response = wp_remote_post( $url, [
    'timeout'             => 5,
    'redirection'         => 2,
    'reject_unsafe_urls'  => true,
] );
```

**Fallback — generic URL with IP-range filtering.** Only use this when
allowlisting is impossible (e.g. user-submitted webhook URLs). The
naive `gethostbyname()` check is **not enough**: it returns a single
IPv4 A record, missing AAAA records (IPv6 ::1, fc00::/7),
multi-record DNS responses, and post-redirect destinations. A correct
generic check needs:

```php
$host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) );

// Reject literal IPs in private/reserved ranges before DNS
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
    if ( ! filter_var( $host, FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
        wp_die( 'Forbidden host', 403 );
    }
}

// Resolve ALL records, not just the first A
$records = array_merge(
    dns_get_record( $host, DNS_A )  ?: [],
    dns_get_record( $host, DNS_AAAA ) ?: []
);
foreach ( $records as $r ) {
    $ip = $r['ip'] ?? $r['ipv6'] ?? '';
    if ( ! filter_var( $ip, FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
        wp_die( 'Forbidden host', 403 );
    }
}

$response = wp_remote_get( $url, [
    'timeout'            => 5,
    'redirection'        => 2,
    'reject_unsafe_urls' => true,
] );
```

Even this is incomplete — DNS rebinding can return safe records to
the resolver and unsafe records to the actual fetch. For full
protection you need to resolve once, fetch by IP with a `Host:`
header. WordPress's `reject_unsafe_urls` request arg does some
filtering via the `http_request_host_is_external` /
`http_request_host_is_allowed` filters, but is opt-in and not
sufficient on its own.

Flag when:
- No host allowlist when the integration only needs known endpoints.
- `gethostbyname()`-only check (audit ourselves: this skill's earlier
  versions had this same bug — IPv4-only, single-record, no redirect
  reverification).
- `redirection` not capped (default follows up to 5 — can chain into
  internal services after passing the initial check).
- `timeout` missing — DoS vector.
- `reject_unsafe_urls` not set on user-influenced URLs.

### 3. CSRF on state-changing GET handlers

WP plugins commonly wire admin pages with `?action=delete&id=42`
links. These bypass `check_admin_referer` if the dev only added it
to POST handlers.

```php
// HIGH — GET with side effect, no nonce
if ( isset( $_GET['action'] ) && $_GET['action'] === 'delete' ) {
    delete_post( (int) $_GET['id'] );
}
```

**Rule:** any handler that writes MUST verify a nonce regardless of
HTTP method. Action links must be built with `wp_nonce_url(
$url, 'delete_post_' . $id )` and verified with
`check_admin_referer( 'delete_post_' . $id )`.

### 4. Mass assignment

```php
// HIGH — user can set role, status, meta_input, etc.
wp_update_user( $_POST );
wp_insert_post( $_POST );

foreach ( $_POST as $key => $value ) {
    update_user_meta( $user_id, $key, $value );
}
```

**Fix:** explicit allowlist of accepted keys. Never spread
`$_POST` / `$_REQUEST` into a write function whose schema includes
privileged fields (`role`, `user_pass`, `post_status`, `post_author`,
`meta_input`, `tax_input`).

### 5. File include / template injection

```php
// HIGH — RCE
include $template_dir . '/' . $_GET['view'] . '.php';
locate_template( $_GET['t'] . '.php' );
```

**Fix:** allowlist:

```php
$allowed = [ 'list', 'edit', 'settings' ];
$view    = isset( $_GET['view'] ) && in_array( $_GET['view'], $allowed, true )
    ? $_GET['view'] : 'list';
include $template_dir . '/' . $view . '.php';
```

Even with `sanitize_file_name`, `..` and null bytes can survive. Only
allowlist is safe.

### 6. Mail header injection

```php
// HIGH — \r\n injection adds Bcc
wp_mail( 'admin@site.com', 'Hi', $body, "From: " . $_POST['email'] );
```

**Fix:** sanitize email + reject CRLF:

```php
$from = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( ! $from || preg_match( '/[\r\n]/', $from ) ) {
    wp_die( 'Invalid sender', 400 );
}
wp_mail( 'admin@site.com', 'Hi', $body, [ 'From: ' . $from ] );
```

Pass headers as an array, not a concatenated string.

### 7. ZipSlip / archive extraction

```php
// HIGH — extracted file can escape with ../
$zip->extractTo( $target_dir );
```

**Fix:** reject suspicious entries explicitly, then containment-check
the resolved path with a trailing separator:

```php
$base_real = realpath( $target_dir );
if ( $base_real === false ) {
    wp_die( 'Invalid target', 500 );
}
$base_with_sep = rtrim( $base_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;

for ( $i = 0; $i < $zip->numFiles; $i++ ) {
    $name = $zip->getNameIndex( $i );

    // Reject obvious traversal / absolute / drive prefixes
    if ( $name === false
         || $name === ''
         || strpos( $name, "\0" ) !== false
         || preg_match( '#(^|[/\\\\])\.\.([/\\\\]|$)#', $name )
         || preg_match( '#^([a-zA-Z]:|/|\\\\)#', $name )
    ) {
        wp_die( 'Malicious archive entry', 400 );
    }

    // Resolve intended target. Note: file does not exist yet, so
    // realpath() returns false — we manually normalize and then
    // require it to be inside $base_with_sep (with the trailing
    // separator, so /base does not pass for /base-evil).
    $candidate = $base_with_sep . $name;
    $normalized = $base_with_sep
        . ltrim( str_replace( '\\', '/', $name ), '/' );

    // After normalization, prefix-check against base+sep.
    if ( strncmp( $normalized, $base_with_sep, strlen( $base_with_sep ) ) !== 0 ) {
        wp_die( 'Archive escape attempt', 400 );
    }
}

// Optionally extract entries one at a time with stream filters that
// also reject symlinks (ZipArchive::extractTo does not honor symlinks
// portably; symlink-bearing archives are a separate audit).
$zip->extractTo( $target_dir );
```

Also: **never use `realpath()` for not-yet-existing paths** — it
returns `false` and the common fallback `$base . '/' . $name` is
exactly the unvalidated string the attacker controls. Always
normalize manually and prefix-check against `base + DIRECTORY_SEPARATOR`.

Reject symlinks, hardlinks, and entries whose archive metadata
indicates non-regular file types if your archive format exposes them.

### 8. Timing-safe comparison

```php
// MEDIUM — timing leak + type juggling
if ( $_GET['token'] == $stored_token ) { /* grant */ }

// '0e123...' == '0e456...' is true (scientific notation)
```

**Fix:** `if ( hash_equals( $stored_token, (string) $_GET['token'] ) )`.

Use for: API keys, password reset tokens, signed URLs, any secret
comparison. Not needed for IDs / user-visible values.

### 9. TOCTOU race on options/meta

```php
// MEDIUM — two simultaneous requests both pass the check
if ( ! get_option( 'myplugin_processing' ) ) {
    update_option( 'myplugin_processing', 1 );
    do_expensive_thing();
    update_option( 'myplugin_processing', 0 );
}
```

WP options have no atomic CAS. Mitigations:

- Use a transient with a short TTL as a soft lock — not perfect, but
  better than the above.
- For real exclusion: `$wpdb->query( "SELECT GET_LOCK('myplugin', 0)" )`
  and `RELEASE_LOCK`. MySQL-level, atomic.
- If correctness matters (billing, idempotency), use a unique-key
  insert as the gate: insert fails → another worker is in flight.

Flag the pattern, propose the lock approach. Don't claim certainty
about race windows without dynamic testing.

### 10. Direct file access guard

Top of every PHP file that has side effects on load:

```php
if ( ! defined( 'ABSPATH' ) ) { exit; }
```

Missing guard is LOW unless the file actually executes work at
top-level (most class files are fine). Flag explicitly for files in
`includes/` or `admin/` that do procedural work.

## Severity guide

Same as `wp-security-audit`. Object injection, SSRF on internal
network, RCE via include, ZipSlip → HIGH. CSRF on admin GET
typically HIGH (any logged-in admin clicking a link). TOCTOU,
timing → MEDIUM unless directly exploitable.

## Report format

Reuse the format from `wp-security-audit`. If both skills run, merge
findings into a single report grouped by severity, not by skill.

## Cross-references

- Run **`wp-security-audit`** first for the basic checklist
  (sanitize, escape, nonce, capability, SQL prepare).
- Run **`wp-security-secrets`** for hardcoded credentials, weak
  randomness in tokens, and password-storage issues — these are
  adjacent but distinct findings.

## What this skill does NOT cover

- Cryptographic protocol correctness (custom JWT, signing schemes).
- Business-logic IDOR beyond capability/ownership checks.
- Third-party dependency CVEs (run `composer audit`).
- Concurrency analysis beyond surface TOCTOU patterns.
- Server hardening (open_basedir, disable_functions, file perms).

## References

- WP Plugin Security Handbook:
  https://developer.wordpress.org/plugins/security/
- PHP unserialize advisory:
  https://www.php.net/manual/en/function.unserialize.php
- Phar metadata RFC (PHP 8.0): https://wiki.php.net/rfc/phar_stop_autoloading_metadata
- WP HTTP API request args: https://developer.wordpress.org/reference/classes/wp_http/request/

Related Skills

wp-security-secrets

5
from nvdigitalsolutions/mcp-ai-wpoos

Audits WordPress plugin/theme code for secret-handling and credential issues — hardcoded API keys, DB credentials, or signing secrets in source; weak randomness (rand, mt_rand, uniqid) used for security tokens, password reset links, nonces, or session IDs; password storage with md5/sha1/crypt instead of password_hash; insecure cookie flags (missing Secure, HttpOnly, SameSite) on sensitive cookies; secrets logged via error_log / var_dump in production code paths. Use before plugin release, when reviewing auth/registration/login features, when integrating third-party APIs, or when the user mentions "API key", "token", "session", "password reset".

wp-security-audit

5
from nvdigitalsolutions/mcp-ai-wpoos

Audits WordPress plugin or theme PHP code for the most common security mistakes — missing nonce checks, capability checks, input sanitization, output escaping, unslashing, SQL preparation, AJAX nopriv exposure, file/path traversal, and unsafe redirects. Use when reviewing pull requests, before releasing a plugin, when the user asks "is this secure", or when handling code that touches $_GET / $_POST / $_REQUEST / $_COOKIE / $_FILES / $_SERVER, admin-ajax / admin-post, REST endpoints, options, user meta, custom DB queries, or file uploads.

webapp-testing

5
from nvdigitalsolutions/mcp-ai-wpoos

Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.

web-artifacts-builder

5
from nvdigitalsolutions/mcp-ai-wpoos

Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.

valyu

5
from nvdigitalsolutions/mcp-ai-wpoos

Search the live web and 36+ specialised data sources including SEC filings, PubMed, ChEMBL, clinical trials, FRED economic indicators, and patent databases. Use when current, authoritative, or paywalled data is required.

ui-ux-pro-max

5
from nvdigitalsolutions/mcp-ai-wpoos

AI-powered design intelligence with 67 UI styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 15+ tech stacks. Generates complete design systems for any product type with industry-specific reasoning rules.

theme-factory

5
from nvdigitalsolutions/mcp-ai-wpoos

Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.

slack-gif-creator

5
from nvdigitalsolutions/mcp-ai-wpoos

Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."

skill-creator

5
from nvdigitalsolutions/mcp-ai-wpoos

Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.

shannon

5
from nvdigitalsolutions/mcp-ai-wpoos

Autonomous AI security pen testing. Executes real exploits against web applications to find SQL injection, XSS, SSRF, authentication flaws, and IDOR vulnerabilities. Reports only confirmed, reproducible findings — no false positives.

remotion

5
from nvdigitalsolutions/mcp-ai-wpoos

Create programmatic videos using React and Remotion. Translate natural language descriptions into working Remotion components for product demos, release announcements, explainer videos, and animated content.

planetscale

5
from nvdigitalsolutions/mcp-ai-wpoos

Design schemas and write queries for PlanetScale serverless MySQL using branch-based workflows. Ensures index coverage, avoids foreign key anti-patterns, and treats every schema change as a reviewable, reversible deploy request.