Claude Skill

wp-security-audit

Audits WordPress plugin or theme PHP code for the most common

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-security-audit-52f6020.zip · 12 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-security-audit
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress security audit

A defensive checklist-driven review for WP plugin and theme PHP. Goal: catch the boring, repeatable mistakes that ship to production because no one ran through the basics. This is not a substitute for a real security review of cryptography, business logic, or third-party deps.

When to use this skill

Trigger this skill when ANY of the following is true:

  • The user asks for a security review, audit, or "is this safe".
  • The diff or file under discussion contains: $_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES, $_SERVER, wp_unslash, wp_verify_nonce, check_admin_referer, current_user_can, add_action( 'wp_ajax, add_action( 'admin_post, register_rest_route, $wpdb->, update_option, update_user_meta, wp_redirect, wp_safe_redirect, file_get_contents, move_uploaded_file.
  • The user is preparing a plugin for release or wp.org submission.
  • The user is reviewing a contributor's PR.

How to run the audit

Work through the Critical checks below in order. For each finding:

  1. State the file and line.
  2. Name the issue using its conventional WP terminology (e.g. "missing nonce", "unescaped output", "broken access control").
  3. Show the offending code (1–3 lines).
  4. Show the fix.
  5. Mark severity: HIGH (exploitable now), MEDIUM (exploitable under conditions), LOW (hardening / best practice).
  6. Mark the evidence status separately from severity:
    • Reproduced — a controlled test reached the sink or observed the effect;
    • Source-proven — the complete deterministic path and prerequisites are visible in the inspected code/core contracts;
    • Environment-dependent hypothesis — the effect needs a deployment property or integration that was not available to test.

Do not present an environment-dependent hypothesis as a confirmed finding. Put it under limitations or required validation, name the missing environment, and do not promote it into a reusable skill rule until it is reproduced or the relevant runtime contract is verified. Severity describes impact and exploitability; it does not compensate for weak evidence.

Do NOT silently rewrite the file. Produce a report first; only edit if the user asks you to apply fixes.

Critical checks

1. Nonce verification on state-changing requests

Any cookie-authenticated browser handler that writes (saves an option, updates meta, deletes a post, sends an email, or mutates anything) must verify request intent with a nonce.

  • Forms: wp_nonce_field( 'action_name', '_wpnonce' ) → check_admin_referer( 'action_name' ) in handler.
  • AJAX: wp_create_nonce( 'action_name' ) → check_ajax_referer( 'action_name', 'nonce' ).
  • REST: rely on cookie auth + the built-in _wpnonce (wp-api nonce) for logged-in routes; for permission_callback use a real capability check.

A nonce is not authentication or authorization. Signed webhooks, Application Password/OAuth clients, cron, and WP-CLI use their own trust boundary instead of a WordPress nonce. Guest nonces do not identify a guest; public writes also need abuse controls such as throttling, replay protection, or CAPTCHA where appropriate. A cacheable, read-only public endpoint does not automatically need a nonce.

Common mistake: verifying the nonce inside an if whose else branch still does the write. The nonce check must short-circuit.

2. Capability checks (authorization)

Authentication ≠ authorization. A logged-in subscriber is still a user.

  • Admin actions: current_user_can( 'manage_options' ) or a more specific capability (edit_posts, edit_post with object ID, manage_woocommerce etc.).
  • Object-level actions MUST pass the object ID: current_user_can( 'edit_post', $post_id ) — the ID-less form is wrong.
  • REST permission_callback must enforce the route's actual access policy. __return_true is dangerous on privileged writes, but can be intentional for genuinely public endpoints. Signed webhook routes should verify the signature before mutation, preferably in permission_callback.
  • In multisite, is_user_member_of_blog() is a membership predicate, not a capability check. WordPress 7.1 adds the is_user_member_of_blog filter, but it runs only after both the user and an active site have resolved. Returning true can affect core REST user/application-password checks and admin UI visibility globally; do not use the filter to manufacture authorization. Keep an object-appropriate current_user_can() check at the protected sink.

3. Input: unslash → normalize when needed → validate

WordPress slashes superglobals. First recover the domain value, then choose lossy normalization only when the field's meaning permits it, and always validate the semantic contract:

$raw      = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';
$email    = sanitize_email( $raw );
if ( ! is_email( $email ) ) { /* reject */ }
  • Missing wp_unslash before normalization can leak transport slashes into stored data. Do not unslash values that did not come from a slashed boundary.
  • Choose the normalizer by field meaning: text, textarea, email, URL, key, integer, allowed HTML, and filesystem path need different contracts. See the extended reference.md map.
  • Never trust $_SERVER['HTTP_*'] headers without sanitizing; they're attacker-controlled.

Sanitization is not a ritual and is often lossy. Migration, import/export, database repair, code-editor, and opaque meta-value tools may need to preserve HTML, percent sequences, quotes, or backslashes exactly. Do not recommend sanitize_text_field() merely to silence a sniff. Require strict type/shape, encoding/size/domain validation, a safe sink, and escaped output. See reference.md for the exact-preservation pattern.

4. Output escaping (XSS)

Escape at the point of output, in the right context:

  • HTML body: esc_html( $x )
  • HTML attribute: esc_attr( $x )
  • URL in href/src: esc_url( $x )
  • Inside <script> JSON: wp_json_encode( $x ), never raw concatenation
  • Translated strings with placeholders: escape the template AND the substituted value separately. esc_html__() only escapes the static template; printf( esc_html__( '%s', 'td' ), $name ) is XSS if $name contains markup. Correct form: printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) ).
  • Already-HTML content (post content): wp_kses_post( $x )

echo $foo; where $foo came from input or DB without escaping → XSS. This is the most common finding in plugin audits.

WordPress 7.1 expanded what Core KSES accepts: tabindex is a global allowed attribute, and safe inline CSS recognizes additional gradient, transform, clip-path, and SVG presentation forms. This is not a bypass and does not make raw HTML safe, but code must not rely on older Core stripping those values as its business rule. When the product needs a narrower policy, pass an explicit allowlist to wp_kses() and test it on every supported Core version. Keep escaping at output even after KSES sanitization.

5. SQL: always prepare

// WRONG
$wpdb->get_results( "SELECT * FROM x WHERE id = $id" );

// RIGHT
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );
  • %d integers, %f floats, %s strings, %i table/column identifiers (WordPress 6.2+). Still use a semantic allowlist for user-selected columns, tables, and sort directions: %i quotes an identifier but does not decide whether that identifier is allowed by the feature.
  • LIKE needs $wpdb->esc_like() BEFORE prepare(): $like = '%' . $wpdb->esc_like( $term ) . '%';
  • Prefer WP_Query / get_posts / get_users over raw SQL where possible.

6. AJAX endpoints

Two hooks, two meanings — confuse them and you ship a vulnerability:

  • wp_ajax_{action} — fires only for logged-in users.
  • wp_ajax_nopriv_{action} — fires for logged-out users.

Rules:

  • Register nopriv ONLY if the feature is genuinely meant for guests (e.g. public search, login form). Never copy-paste both registrations "to be safe".
  • Cookie-authenticated writes need check_ajax_referer(). Public read-only handlers do not automatically need a nonce; use one only when it protects a browser action, and never treat a guest nonce as authorization.
  • The nopriv handler must NOT perform actions that only logged-in users should do (saving prefs, accessing other users' data, etc.).
  • End with wp_send_json_success / wp_send_json_error, not echo + die.

7. admin-post and form handlers

admin_post_{action} and admin_post_nopriv_{action} follow the same rules as AJAX. Plus: redirect with wp_safe_redirect() + exit;. Never redirect with wp_redirect( $_GET['redirect_to'] ) without validating against an allowlist — that's an open-redirect.

8. REST API routes

register_rest_route( 'myplugin/v1', '/save', [
    'methods'             => 'POST',
    'callback'            => 'myplugin_save',
    'permission_callback' => function () {
        return current_user_can( 'manage_options' );
    },
    'args' => [
        'id' => [
            'required'          => true,
            'type'              => 'integer',
            'minimum'           => 1,
            'validate_callback' => 'rest_validate_request_arg',
            'sanitize_callback' => 'absint',
        ],
    ],
] );

Findings to flag:

  • permission_callback missing, or __return_true on a non-public route.
  • No args schema and no equivalent validation in the callback. A route may validate manually, but a schema is preferred because it is discoverable and runs consistently before permission and endpoint callbacks.
  • Returning raw DB rows including sensitive columns (user_pass, user_activation_key, private meta).

9. File operations

  • Uploads: validate MIME via wp_check_filetype_and_ext(), store via wp_handle_upload(), never trust the client-provided extension or MIME.
  • Path joins with user input: after building, realpath() and check the result starts with the intended base dir. Otherwise: path traversal.
  • Never include / require a path containing user input.

10. Secrets and information disclosure

  • No API keys or DB credentials in the plugin source. Use options or constants in wp-config.php.
  • WP_DEBUG_DISPLAY should be off in prod; flag any var_dump, print_r, error_log( $sensitive ) left in handlers.
  • Don't leak stack traces, full SQL, or user enumeration via error messages ("user not found" vs "wrong password" — pick one).

11. Redirects

  • Use wp_safe_redirect() for any URL that may be influenced by input.
  • Always exit; after a redirect — execution continues otherwise.

12. Cron and background jobs

  • wp_schedule_event callbacks run with no current user. If the job performs privileged work, do not trust any "stored intent" without re-validating; treat persisted user input as untrusted.

False-positive guards

  • Accept exact preservation when its validation, sink, and output contracts are explicit.
  • Do not require a nonce for read-only public endpoints, cron, WP-CLI, or signed non-cookie requests; identify the actual trust boundary.
  • Do not flag code-generated IN placeholders as injection when the placeholder string contains only generated %s/%d tokens and all values reach $wpdb->prepare().

What this skill does NOT cover

  • Cryptographic correctness (key derivation, signing schemes).
  • Business-logic flaws (race conditions, IDOR beyond capability checks).
  • Retry/idempotency/partial-failure flaws in bulk writes — use wp-batch-mutation-audit.
  • Metadata slashing/revision/multi-row/serialization — use wp-metadata-api.
  • Third-party library CVEs — run composer audit separately.
  • Frontend JS XSS — different skill.
  • Server / hosting hardening (file perms, disable_functions, etc.).
  • Object injection, SSRF, CSRF on GET, mass assignment, file include, mail/zip injection, timing comparison, TOCTOU races — out of scope here; they need a separate, deeper pass after this one.
  • Hardcoded credentials, weak randomness for tokens, password storage, cookie flags, secrets in logs — also out of scope; review them separately whenever auth or third-party integrations are in scope.

State this scope and recommend applicable deeper skills in the report footer.

Report format

# Security audit: <plugin name>
Scope: <files reviewed>
Date: <YYYY-MM-DD>

## HIGH
1. <file>:<line> — <issue>
   Evidence: <Reproduced | Source-proven>
   <code>
   Fix: <code>

## MEDIUM
...

## LOW / Hardening
...

## Out of scope
- <thing not checked>

## Requires environment validation
- <hypothesis, missing deployment property, exact acceptance test>

References

Files (wp-agent-skills)
  • examples
    • before-after-ajax.md 2.2 KB
      # Example: AJAX handler — before / after
      
      A realistic plugin pattern showing six findings in ~20 lines, then the
      corrected version.
      
      ## Before (vulnerable)
      
      ```php
      add_action( 'wp_ajax_nopriv_save_pref', 'myplugin_save_pref' );
      add_action( 'wp_ajax_save_pref', 'myplugin_save_pref' );
      
      function myplugin_save_pref() {
          $user_id = $_POST['user_id'];
          $color   = $_POST['color'];
      
          global $wpdb;
          $wpdb->query(
              "UPDATE {$wpdb->prefix}prefs SET color = '$color' WHERE user_id = $user_id"
          );
      
          echo "<div>Saved color: $color</div>";
          die();
      }
      ```
      
      Findings:
      
      1. **HIGH — broken access control**: `wp_ajax_nopriv_*` exposes a
         user-data write to guests.
      2. **HIGH — missing nonce**: no `check_ajax_referer`.
      3. **HIGH — missing capability check**: any logged-in user can save for
         any other user (`$_POST['user_id']` is attacker-controlled).
      4. **HIGH — SQL injection**: raw interpolation of `$color` and `$user_id`.
      5. **HIGH — reflected XSS**: `echo "...$color..."` without escaping.
      6. **LOW — wrong response**: `echo` + `die` instead of
         `wp_send_json_success`.
      
      ## After (fixed)
      
      ```php
      add_action( 'wp_ajax_save_pref', 'myplugin_save_pref' );
      // no nopriv: this is a per-user preference write
      
      function myplugin_save_pref() {
          check_ajax_referer( 'myplugin_save_pref', 'nonce' );
      
          if ( ! is_user_logged_in() ) {
              wp_send_json_error( [ 'message' => 'Forbidden' ], 403 );
          }
      
          $user_id = get_current_user_id(); // never trust $_POST for identity
          $color   = isset( $_POST['color'] )
              ? sanitize_hex_color( wp_unslash( $_POST['color'] ) )
              : '';
      
          if ( ! $color ) {
              wp_send_json_error( [ 'message' => 'Invalid color' ], 400 );
          }
      
          global $wpdb;
          $wpdb->update(
              "{$wpdb->prefix}prefs",
              [ 'color' => $color ],
              [ 'user_id' => $user_id ],
              [ '%s' ],
              [ '%d' ]
          );
      
          wp_send_json_success( [ 'color' => $color ] );
      }
      ```
      
      Notes:
      
      - Identity comes from `get_current_user_id()`, never the request body.
      - `$wpdb->update` with format arrays is equivalent to `prepare`.
      - `sanitize_hex_color` returns `null` for invalid input, giving a clean
        rejection path.
      - `wp_send_json_*` sets `Content-Type: application/json` and exits.
      
  • reference.md 12.5 KB
    # Security audit — extended reference
    
    Read this file when the SKILL.md checklist is not enough — typically when
    the user asks for explanations, when a finding is borderline, or when you
    need a known-good fix to copy.
    
    ## 1. Nonce: the full lifecycle
    
    ### Form submission
    
    ```php
    // In the form template
    ?>
    <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
        <?php wp_nonce_field( 'myplugin_save_settings', 'myplugin_nonce' ); ?>
        <input type="hidden" name="action" value="myplugin_save_settings" />
        <input type="text" name="site_title" />
        <button type="submit">Save</button>
    </form>
    <?php
    
    // In the handler
    add_action( 'admin_post_myplugin_save_settings', function () {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( 'Forbidden', 403 );
        }
        if ( ! isset( $_POST['myplugin_nonce'] )
             || ! wp_verify_nonce(
                    sanitize_key( wp_unslash( $_POST['myplugin_nonce'] ) ),
                    'myplugin_save_settings'
                )
        ) {
            wp_die( 'Invalid request', 403 );
        }
    
        $title = isset( $_POST['site_title'] )
            ? sanitize_text_field( wp_unslash( $_POST['site_title'] ) )
            : '';
    
        update_option( 'myplugin_site_title', $title );
    
        wp_safe_redirect( admin_url( 'options-general.php?page=myplugin&updated=1' ) );
        exit;
    } );
    ```
    
    Note the order: capability → nonce → unslash → sanitize → write → redirect → exit.
    
    ### AJAX
    
    ```php
    // Localize the nonce to JS
    wp_localize_script( 'myplugin', 'MYPLUGIN', [
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'myplugin_save' ),
    ] );
    
    // Handler
    add_action( 'wp_ajax_myplugin_save', function () {
        check_ajax_referer( 'myplugin_save', 'nonce' );
    
        if ( ! current_user_can( 'edit_posts' ) ) {
            wp_send_json_error( [ 'message' => 'Forbidden' ], 403 );
        }
    
        $value = isset( $_POST['value'] )
            ? sanitize_text_field( wp_unslash( $_POST['value'] ) )
            : '';
    
        update_user_meta( get_current_user_id(), 'myplugin_value', $value );
        wp_send_json_success( [ 'saved' => true ] );
    } );
    ```
    
    `check_ajax_referer()` calls `wp_die` on failure — you don't need to
    short-circuit manually.
    
    ## 2. Capability checks: object-level matters
    
    Wrong:
    ```php
    if ( current_user_can( 'edit_post' ) ) { /* always true for editors */ }
    ```
    
    Right:
    ```php
    if ( current_user_can( 'edit_post', $post_id ) ) { /* checks THIS post */ }
    ```
    
    Common object-aware capabilities: `edit_post`, `delete_post`, `read_post`,
    `edit_user`, `delete_user`, `edit_term`, `manage_term`.
    
    Plugin-defined caps (WooCommerce: `edit_shop_order`, `manage_woocommerce`)
    follow the same rule when an ID is meaningful.
    
    ## 3. Sanitization map by intent
    
    | Data | Storage | Output |
    |---|---|---|
    | Plain text (one line) | `sanitize_text_field( wp_unslash( $x ) )` | `esc_html( $x )` |
    | Multiline text | `sanitize_textarea_field( wp_unslash( $x ) )` | `nl2br( esc_html( $x ) )` |
    | Email | `sanitize_email( wp_unslash( $x ) )` | `esc_html( $x )` |
    | URL | `esc_url_raw( wp_unslash( $x ) )` | `esc_url( $x )` |
    | Slug / key | `sanitize_key( wp_unslash( $x ) )` | `esc_attr( $x )` |
    | Integer | `absint( $x )` or `(int) $x` | `(int) $x` |
    | Float | `(float) $x` | `(float) $x` |
    | HTML (limited) | `wp_kses_post( wp_unslash( $x ) )` | echo as-is |
    | HTML attribute | sanitize per type | `esc_attr( $x )` |
    | Hex color | `sanitize_hex_color( $x )` | `esc_attr( $x )` |
    | Filename | `sanitize_file_name( $x )` | `esc_html( $x )` |
    | JSON for `<script>` | structured array | `wp_json_encode( $x )` |
    
    Do not HTML-escape before storage. Validate and sanitize input into a
    canonical value, store that value, then escape once for the exact output
    context. Pre-escaping creates corrupted/double-escaped data and still does not
    protect a later, different output context.
    
    ### Exact-preservation tools
    
    Sanitizers are lossy normalizers, not a universal proof of safety. Migration,
    import/export, database repair, code-editor, and opaque meta tools can need to
    preserve HTML, percent sequences, quotes, and backslashes exactly.
    
    ```php
    $value = isset( $_POST['value'] ) && is_string( $_POST['value'] )
        ? wp_check_invalid_utf8( wp_unslash( $_POST['value'] ) )
        : '';
    
    if ( '' === $value || strlen( $value ) > 65536 ) {
        wp_die( 'Invalid value', 400 );
    }
    
    // Use only through a prepared statement or an API with a defined slash contract.
    // Escape by context if this value is ever rendered.
    ```
    
    This is acceptable only when the feature defines the exact type/shape,
    encoding, byte limit, allowed operation, safe storage sink, and output
    escaping. It does not permit raw HTML output, dynamic SQL, arbitrary file paths,
    or executable template content. Do not replace an opaque value with
    `sanitize_text_field()` solely to satisfy a static-analysis warning.
    
    ## 4. Why `wp_unslash` matters
    
    WP runs `add_magic_quotes()` on all superglobals at request init. Without
    `wp_unslash`, a quote `'` becomes `\'` BEFORE sanitization, which:
    - Breaks comparison logic.
    - Stores the backslash literally in the DB.
    - Lets an attacker craft input that survives escaping in unexpected ways.
    
    Rule: recover string/array domain values from `$_GET / $_POST / $_COOKIE /
    $_REQUEST` with `wp_unslash` before normalization. Do not apply `wp_unslash` to
    data that did not cross WordPress's slashed superglobal boundary. Direct numeric
    casts can validate a numeric request field without preserving quote characters.
    
    ## 5. SQL: prepared statements in detail
    
    ### Safe
    
    ```php
    $wpdb->prepare(
        "SELECT id, name FROM {$wpdb->prefix}myplugin_items
         WHERE user_id = %d AND status = %s LIMIT %d",
        $user_id, $status, $limit
    );
    ```
    
    ### Table/column names — `%i` plus a semantic allowlist
    
    ```php
    $allowed_orderby = [ 'id', 'name', 'created_at' ];
    $orderby = in_array( $orderby_input, $allowed_orderby, true )
        ? $orderby_input : 'id';
    $order   = strtoupper( $order_input ) === 'DESC' ? 'DESC' : 'ASC';
    
    $sql = $wpdb->prepare(
        "SELECT * FROM %i ORDER BY %i {$order} LIMIT %d",
        $wpdb->prefix . 'items',
        $orderby,
        $limit
    );
    ```
    
    `%i` is available since WordPress 6.2 and safely quotes identifiers. It does
    not replace the allowlist: the allowlist is the authorization/business rule
    for which identifiers the request may select. SQL keywords such as `ASC` and
    `DESC` are not identifiers and must be selected from a fixed allowlist.
    
    ### LIKE
    
    ```php
    $like = '%' . $wpdb->esc_like( $term ) . '%';
    $wpdb->prepare( "SELECT * FROM x WHERE name LIKE %s", $like );
    ```
    
    ### IN clauses
    
    ```php
    $ids          = array_map( 'absint', (array) $ids );
    $ids          = array_values( array_filter( $ids ) );
    if ( [] === $ids ) {
        return [];
    }
    $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
    $sql          = $wpdb->prepare(
        "SELECT * FROM x WHERE id IN ($placeholders)", $ids
    );
    ```
    
    ## 6. REST: the full secure pattern
    
    ```php
    add_action( 'rest_api_init', function () {
        register_rest_route( 'myplugin/v1', '/items/(?P<id>\d+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => 'myplugin_rest_get_item',
                'permission_callback' => function ( $request ) {
                    return current_user_can( 'read_post', (int) $request['id'] );
                },
                'args' => [
                    'id' => [
                        'validate_callback' => static fn( $v ) => is_numeric( $v ) && (int) $v > 0,
                        'sanitize_callback' => 'absint',
                    ],
                ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => 'myplugin_rest_update_item',
                'permission_callback' => function ( $request ) {
                    return current_user_can( 'edit_post', (int) $request['id'] );
                },
                'args' => [
                    'id'    => [ 'sanitize_callback' => 'absint' ],
                    'title' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                        'validate_callback' => static fn( $v ) => is_string( $v ) && strlen( $v ) <= 200,
                    ],
                ],
            ],
        ] );
    } );
    ```
    
    Findings to flag in REST audits:
    - `permission_callback => '__return_true'` on privileged writes, or a public
      write that lacks its required signature, token, or abuse controls.
    - Missing `validate_callback` AND missing manual validation in handler.
    - Returning `WP_User` objects directly — leaks `user_pass` hash, email,
      capabilities of other users.
    - `register_rest_route` before `rest_api_init`. Core emits `_doing_it_wrong()`
      but still registers the route; treat this as lifecycle misuse, not a route
      that silently disappears.
    
    ## 7. AJAX nopriv: when is it actually correct?
    
    Legitimate uses:
    - Public search / filter endpoints that read public data only.
    - Login / registration / password reset forms.
    - Public contact forms (nonce for browser intent when useful, plus spam and
      rate-limit controls; a guest nonce is not guest authentication).
    
    Illegitimate ("just in case") uses:
    - Saving any user-specific preference.
    - Anything that touches another user's data.
    - Anything gated by capability — guests have none.
    
    If you flag a `wp_ajax_nopriv_*` registration, ask: "what's the
    legitimate guest use case?" If the answer is unclear, that's a HIGH
    finding.
    
    ## 8. Open redirects
    
    ```php
    // VULNERABLE
    wp_redirect( $_GET['redirect_to'] );
    
    // SAFE
    $target = isset( $_GET['redirect_to'] )
        ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) )
        : '';
    wp_safe_redirect( $target ); // limits to allowed hosts
    exit;
    ```
    
    `wp_safe_redirect` only allows the site's own host (extendable via
    `allowed_redirect_hosts` filter). Always pair with `exit;`.
    
    ## 9. Path traversal
    
    ```php
    $filename = sanitize_file_name( wp_unslash( $_GET['file'] ?? '' ) );
    
    $base      = wp_upload_dir()['basedir'] . '/myplugin';
    $base_real = realpath( $base );
    if ( $base_real === false ) {
        wp_die( 'Invalid base', 500 );
    }
    $base_real = rtrim( $base_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
    
    $path = realpath( $base_real . $filename );
    
    // Trailing separator on BOTH sides is required — otherwise
    // /var/.../myplugin would also match /var/.../myplugin-evil/ under
    // a plain str_starts_with / strpos prefix check.
    if ( $path === false
         || strncmp( $path . DIRECTORY_SEPARATOR, $base_real, strlen( $base_real ) ) !== 0
    ) {
        wp_die( 'Forbidden', 403 );
    }
    
    // safe to read $path
    ```
    
    `sanitize_file_name` alone is not enough — `..%2f` and Unicode tricks
    can survive. The `realpath` containment check is the actual guard,
    **but only with a trailing-separator-aware comparison**: the naive
    `strpos( $path, realpath( $base ) ) !== 0` is vulnerable to sibling
    directories with the same prefix (`/srv/x` vs `/srv/x-evil`).
    
    When possible, prefer an allowlist of file IDs over filename
    parameters entirely:
    
    ```php
    $id      = absint( $_GET['file_id'] ?? 0 );
    $row     = $wpdb->get_row( $wpdb->prepare( "SELECT path FROM ... WHERE id = %d", $id ) );
    $path    = $row ? realpath( $row->path ) : false; // path is plugin-controlled
    ```
    
    This eliminates the user-controlled filename from the equation.
    
    ## 10. Severity guide
    
    **HIGH** — exploitable by an unauthenticated attacker, or by any
    logged-in user against another user / the whole site:
    - SQL injection, stored XSS reachable by guests, missing auth on
      state-changing AJAX/REST, arbitrary file read/write/delete, RCE,
      privilege escalation, open redirect on auth flow.
    
    **MEDIUM** — exploitable but conditioned (logged-in attacker with low
    role, requires specific config, requires social engineering):
    - Reflected XSS in admin, CSRF on settings change, stored XSS only
      visible to admins, info disclosure of non-secret data.
    
    **LOW / Hardening** — best practice violation, no direct exploit
    demonstrated:
    - Missing `esc_attr` where the value is currently safe but could become
      unsafe, missing `wp_unslash` where input is numeric, debug code left
      in, weak validation that's caught downstream.
    
    ## 11. False positives — don't flag these
    
    - `echo (int) $x;` — already escaped by cast.
    - `echo $wpdb->prepare(...)` of a constant string — no input.
    - `current_user_can` followed by `&&` short-circuit before write.
    - `wp_kses_post` output of post content fetched via `get_the_content` —
      WP already filtered.
    - Translations: `esc_html_e( 'Hello', 'td' )` — already escaped.
    - `__()` used INSIDE `printf( '...%s...', esc_html( __( ... ) ) )`.
    - Missing `sanitize_text_field()` on an exact-preservation value whose
      type/size/encoding, safe sink, and output escaping are all explicit.
    
  • SKILL.md 14.1 KB
    ---
    name: wp-security-audit
    description: Audits WordPress plugin or theme PHP code for the most common
      security mistakes — missing nonce checks, capability checks, input
      normalization/validation, 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.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress security audit
    
    A defensive checklist-driven review for WP plugin and theme PHP. Goal:
    catch the boring, repeatable mistakes that ship to production because no
    one ran through the basics. This is **not** a substitute for a real
    security review of cryptography, business logic, or third-party deps.
    
    ## When to use this skill
    
    Trigger this skill when ANY of the following is true:
    
    - The user asks for a security review, audit, or "is this safe".
    - The diff or file under discussion contains: `$_GET`, `$_POST`,
      `$_REQUEST`, `$_COOKIE`, `$_FILES`, `$_SERVER`, `wp_unslash`,
      `wp_verify_nonce`, `check_admin_referer`, `current_user_can`,
      `add_action( 'wp_ajax`, `add_action( 'admin_post`,
      `register_rest_route`, `$wpdb->`, `update_option`, `update_user_meta`,
      `wp_redirect`, `wp_safe_redirect`, `file_get_contents`, `move_uploaded_file`.
    - The user is preparing a plugin for release or wp.org submission.
    - The user is reviewing a contributor's PR.
    
    ## How to run the audit
    
    Work through the **Critical checks** below in order. For each finding:
    
    1. State the file and line.
    2. Name the issue using its conventional WP terminology
       (e.g. "missing nonce", "unescaped output", "broken access control").
    3. Show the offending code (1–3 lines).
    4. Show the fix.
    5. Mark severity: **HIGH** (exploitable now), **MEDIUM** (exploitable under
       conditions), **LOW** (hardening / best practice).
    6. Mark the evidence status separately from severity:
       - **Reproduced** — a controlled test reached the sink or observed the effect;
       - **Source-proven** — the complete deterministic path and prerequisites are
         visible in the inspected code/core contracts;
       - **Environment-dependent hypothesis** — the effect needs a deployment
         property or integration that was not available to test.
    
    Do not present an environment-dependent hypothesis as a confirmed finding. Put
    it under limitations or required validation, name the missing environment, and
    do not promote it into a reusable skill rule until it is reproduced or the
    relevant runtime contract is verified. Severity describes impact and
    exploitability; it does not compensate for weak evidence.
    
    Do NOT silently rewrite the file. Produce a report first; only edit if the
    user asks you to apply fixes.
    
    ## Critical checks
    
    ### 1. Nonce verification on state-changing requests
    
    Any cookie-authenticated browser handler that *writes* (saves an option,
    updates meta, deletes a post, sends an email, or mutates anything) must
    verify request intent with a nonce.
    
    - Forms: `wp_nonce_field( 'action_name', '_wpnonce' )` →
      `check_admin_referer( 'action_name' )` in handler.
    - AJAX: `wp_create_nonce( 'action_name' )` → `check_ajax_referer( 'action_name', 'nonce' )`.
    - REST: rely on cookie auth + the built-in `_wpnonce` (`wp-api` nonce) for
      logged-in routes; for `permission_callback` use a real capability check.
    
    A nonce is not authentication or authorization. Signed webhooks,
    Application Password/OAuth clients, cron, and WP-CLI use their own trust
    boundary instead of a WordPress nonce. Guest nonces do not identify a guest;
    public writes also need abuse controls such as throttling, replay protection,
    or CAPTCHA where appropriate. A cacheable, read-only public endpoint does not
    automatically need a nonce.
    
    **Common mistake:** verifying the nonce inside an `if` whose `else` branch
    still does the write. The nonce check must short-circuit.
    
    ### 2. Capability checks (authorization)
    
    Authentication ≠ authorization. A logged-in subscriber is still a user.
    
    - Admin actions: `current_user_can( 'manage_options' )` or a more
      specific capability (`edit_posts`, `edit_post` with object ID,
      `manage_woocommerce` etc.).
    - Object-level actions MUST pass the object ID:
      `current_user_can( 'edit_post', $post_id )` — the ID-less form is wrong.
    - REST `permission_callback` must enforce the route's actual access policy.
      `__return_true` is dangerous on privileged writes, but can be intentional
      for genuinely public endpoints. Signed webhook routes should verify the
      signature before mutation, preferably in `permission_callback`.
    - In multisite, `is_user_member_of_blog()` is a membership predicate, not a
      capability check. WordPress 7.1 adds the `is_user_member_of_blog` filter, but
      it runs only after both the user and an active site have resolved. Returning
      `true` can affect core REST user/application-password checks and admin UI
      visibility globally; do not use the filter to manufacture authorization.
      Keep an object-appropriate `current_user_can()` check at the protected sink.
    
    ### 3. Input: unslash → normalize when needed → validate
    
    WordPress slashes superglobals. First recover the domain value, then choose
    lossy normalization only when the field's meaning permits it, and always
    validate the semantic contract:
    
    ```php
    $raw      = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';
    $email    = sanitize_email( $raw );
    if ( ! is_email( $email ) ) { /* reject */ }
    ```
    
    - Missing `wp_unslash` before normalization can leak transport slashes into
      stored data. Do not unslash values that did not come from a slashed boundary.
    - Choose the normalizer by field meaning: text, textarea, email, URL, key,
      integer, allowed HTML, and filesystem path need different contracts. See the
      extended `reference.md` map.
    - Never trust `$_SERVER['HTTP_*']` headers without sanitizing; they're
      attacker-controlled.
    
    Sanitization is not a ritual and is often lossy. Migration, import/export,
    database repair, code-editor, and opaque meta-value tools may need to preserve
    HTML, percent sequences, quotes, or backslashes exactly. Do not recommend
    `sanitize_text_field()` merely to silence a sniff. Require strict type/shape,
    encoding/size/domain validation, a safe sink, and escaped output. See
    `reference.md` for the exact-preservation pattern.
    
    ### 4. Output escaping (XSS)
    
    Escape **at the point of output**, in the right context:
    
    - HTML body: `esc_html( $x )`
    - HTML attribute: `esc_attr( $x )`
    - URL in `href`/`src`: `esc_url( $x )`
    - Inside `<script>` JSON: `wp_json_encode( $x )`, never raw concatenation
    - Translated strings with placeholders: escape the **template** AND
      the **substituted value** separately. `esc_html__()` only escapes
      the static template; `printf( esc_html__( '%s', 'td' ), $name )` is
      XSS if `$name` contains markup. Correct form:
      `printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) )`.
    - Already-HTML content (post content): `wp_kses_post( $x )`
    
    `echo $foo;` where `$foo` came from input or DB without escaping → XSS.
    This is the most common finding in plugin audits.
    
    WordPress 7.1 expanded what Core KSES accepts: `tabindex` is a global allowed
    attribute, and safe inline CSS recognizes additional gradient, transform,
    `clip-path`, and SVG presentation forms. This is not a bypass and does not make
    raw HTML safe, but code must not rely on older Core stripping those values as
    its business rule. When the product needs a narrower policy, pass an explicit
    allowlist to `wp_kses()` and test it on every supported Core version. Keep
    escaping at output even after KSES sanitization.
    
    ### 5. SQL: always prepare
    
    ```php
    // WRONG
    $wpdb->get_results( "SELECT * FROM x WHERE id = $id" );
    
    // RIGHT
    $wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );
    ```
    
    - `%d` integers, `%f` floats, `%s` strings, `%i` table/column identifiers
      (WordPress 6.2+). Still use a semantic allowlist for user-selected columns,
      tables, and sort directions: `%i` quotes an identifier but does not decide
      whether that identifier is allowed by the feature.
    - `LIKE` needs `$wpdb->esc_like()` BEFORE `prepare()`:
      `$like = '%' . $wpdb->esc_like( $term ) . '%';`
    - Prefer WP_Query / get_posts / get_users over raw SQL where possible.
    
    ### 6. AJAX endpoints
    
    Two hooks, two meanings — confuse them and you ship a vulnerability:
    
    - `wp_ajax_{action}` — fires only for **logged-in** users.
    - `wp_ajax_nopriv_{action}` — fires for **logged-out** users.
    
    Rules:
    - Register `nopriv` ONLY if the feature is genuinely meant for guests
      (e.g. public search, login form). Never copy-paste both registrations
      "to be safe".
    - Cookie-authenticated writes need `check_ajax_referer()`. Public read-only
      handlers do not automatically need a nonce; use one only when it protects
      a browser action, and never treat a guest nonce as authorization.
    - The `nopriv` handler must NOT perform actions that only logged-in users
      should do (saving prefs, accessing other users' data, etc.).
    - End with `wp_send_json_success` / `wp_send_json_error`, not `echo` + `die`.
    
    ### 7. admin-post and form handlers
    
    `admin_post_{action}` and `admin_post_nopriv_{action}` follow the same
    rules as AJAX. Plus: redirect with `wp_safe_redirect()` + `exit;`. Never
    redirect with `wp_redirect( $_GET['redirect_to'] )` without validating
    against an allowlist — that's an open-redirect.
    
    ### 8. REST API routes
    
    ```php
    register_rest_route( 'myplugin/v1', '/save', [
        'methods'             => 'POST',
        'callback'            => 'myplugin_save',
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },
        'args' => [
            'id' => [
                'required'          => true,
                'type'              => 'integer',
                'minimum'           => 1,
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'absint',
            ],
        ],
    ] );
    ```
    
    Findings to flag:
    - `permission_callback` missing, or `__return_true` on a non-public route.
    - No `args` schema and no equivalent validation in the callback. A route may
      validate manually, but a schema is preferred because it is discoverable
      and runs consistently before permission and endpoint callbacks.
    - Returning raw DB rows including sensitive columns (`user_pass`,
      `user_activation_key`, private meta).
    
    ### 9. File operations
    
    - Uploads: validate MIME via `wp_check_filetype_and_ext()`, store via
      `wp_handle_upload()`, never trust the client-provided extension or MIME.
    - Path joins with user input: after building, `realpath()` and check the
      result starts with the intended base dir. Otherwise: path traversal.
    - Never `include` / `require` a path containing user input.
    
    ### 10. Secrets and information disclosure
    
    - No API keys or DB credentials in the plugin source. Use options or
      constants in `wp-config.php`.
    - `WP_DEBUG_DISPLAY` should be off in prod; flag any `var_dump`,
      `print_r`, `error_log( $sensitive )` left in handlers.
    - Don't leak stack traces, full SQL, or user enumeration via error
      messages ("user not found" vs "wrong password" — pick one).
    
    ### 11. Redirects
    
    - Use `wp_safe_redirect()` for any URL that may be influenced by input.
    - Always `exit;` after a redirect — execution continues otherwise.
    
    ### 12. Cron and background jobs
    
    - `wp_schedule_event` callbacks run with no current user. If the job
      performs privileged work, do not trust any "stored intent" without
      re-validating; treat persisted user input as untrusted.
    
    ## False-positive guards
    
    - Accept exact preservation when its validation, sink, and output contracts are
      explicit.
    - Do not require a nonce for read-only public endpoints, cron, WP-CLI, or signed
      non-cookie requests; identify the actual trust boundary.
    - Do not flag code-generated `IN` placeholders as injection when the placeholder
      string contains only generated `%s`/`%d` tokens and all values reach
      `$wpdb->prepare()`.
    
    ## What this skill does NOT cover
    
    - Cryptographic correctness (key derivation, signing schemes).
    - Business-logic flaws (race conditions, IDOR beyond capability checks).
    - Retry/idempotency/partial-failure flaws in bulk writes — use
      **`wp-batch-mutation-audit`**.
    - Metadata slashing/revision/multi-row/serialization — use **`wp-metadata-api`**.
    - Third-party library CVEs — run `composer audit` separately.
    - Frontend JS XSS — different skill.
    - Server / hosting hardening (file perms, disable_functions, etc.).
    - Object injection, SSRF, CSRF on GET, mass assignment, file include,
      mail/zip injection, timing comparison, TOCTOU races — out of scope here;
      they need a separate, deeper pass after this one.
    - Hardcoded credentials, weak randomness for tokens, password
      storage, cookie flags, secrets in logs — also out of scope; review them
      separately whenever auth or third-party integrations are in scope.
    
    State this scope and recommend applicable deeper skills in the report footer.
    
    ## Report format
    
    ```
    # Security audit: <plugin name>
    Scope: <files reviewed>
    Date: <YYYY-MM-DD>
    
    ## HIGH
    1. <file>:<line> — <issue>
       Evidence: <Reproduced | Source-proven>
       <code>
       Fix: <code>
    
    ## MEDIUM
    ...
    
    ## LOW / Hardening
    ...
    
    ## Out of scope
    - <thing not checked>
    
    ## Requires environment validation
    - <hypothesis, missing deployment property, exact acceptance test>
    ```
    
    ## References
    
    - Detailed examples of each finding type, before/after: `reference.md`
    - Real-world snippets with the fix applied: `examples/`
    - WordPress core: [Plugin Security Handbook](https://developer.wordpress.org/plugins/security/) and [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities/)
    - Official documentation: <https://developer.wordpress.org/apis/security/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_verify_nonce/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/current_user_can/>
    - WordPress 7.1 source: `wp-includes/user.php` (`is_user_member_of_blog`).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related