Wp security audit
Skill Lonsdale201/wp-agent-skills/wordpress/wp-security-audit
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.From its SKILL.md
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-security-auditAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
13.1 KB, ~3.0k tokens by cl100k_base, as published. Nobody here has run it
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:
- State the file and line.
- Name the issue using its conventional WP terminology (e.g. "missing nonce", "unescaped output", "broken access control").
- Show the offending code (1–3 lines).
- Show the fix.
- Mark severity: HIGH (exploitable now), MEDIUM (exploitable under conditions), LOW (hardening / best practice).
- 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-apinonce) for logged-in routes; forpermission_callbackuse 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_postwith object ID,manage_woocommerceetc.). - Object-level actions MUST pass the object ID:
current_user_can( 'edit_post', $post_id )— the ID-less form is wrong. - REST
permission_callbackmust enforce the route's actual access policy.__return_trueis dangerous on privileged writes, but can be intentional for genuinely public endpoints. Signed webhook routes should verify the signature before mutation, preferably inpermission_callback.
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_unslashbefore 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.mdmap. - 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$namecontains 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.
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 ) );
%dintegers,%ffloats,%sstrings,%itable/column identifiers (WordPress 6.2+). Still use a semantic allowlist for user-selected columns, tables, and sort directions:%iquotes an identifier but does not decide whether that identifier is allowed by the feature.LIKEneeds$wpdb->esc_like()BEFOREprepare():$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
noprivONLY 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
noprivhandler 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, notecho+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_callbackmissing, or__return_trueon a non-public route.- No
argsschema 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 viawp_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/requirea 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_DISPLAYshould be off in prod; flag anyvar_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_eventcallbacks 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
INplaceholders as injection when the placeholder string contains only generated%s/%dtokens 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 auditseparately. - 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 — these are
covered by
wp-security-deep. Run it after this one. - Hardcoded credentials, weak randomness for tokens, password
storage, cookie flags, secrets in logs — covered by
wp-security-secrets. Run it 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 and 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/
What ships with it: 2 files
14.7 KB alongside SKILL.md
examples/
- before-after-ajax.md2.2 KB
- reference.md12.5 KB