Wp security deep
Skill Lonsdale201/wp-agent-skills/wordpress/wp-security-deep
A community-maintained collection of agent skills for WordPress plugin and theme development.
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-security-deepAssembled 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.
What its author says it does
Copied from the file, not written here
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, self-rolled auth tokens or login rate-limiters, remote SQL/report definitions, or private plugin update channels.
SKILL.md
13.8 KB, ~3.1k tokens by cl100k_base, as published. Nobody here has run it
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_unserializeon stored or transmitted data. - Calls
wp_remote_*,file_get_contents,curl_*,fopenwith 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/$_REQUESTkeys intoupdate_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 ofhash_equals. - Implements its own lock / counter via
get_option+update_option. - Implements login throttling or account lockout from unauthenticated failures.
- Uses a remote response to choose SQL, PHP/template code, callback/class names, filesystem paths, redirects, or a plugin-update package URL.
Audit checks
1. PHP object injection via unserialize
// HIGH — attacker-controlled serialized payload → gadget chains
$data = unserialize( $_POST['payload'] );
Before assigning severity, identify who can write the raw serialized bytes,
whether classes and usable gadgets are loaded, and which magic method creates
the security effect. maybe_unserialize( get_option(...) ) is not automatically
vulnerable; state the required writer instead of assuming either admin-only or
attacker-controlled storage.
Prefer JSON for network/user input. allowed_classes => false blocks normal
class instantiation but not huge/deep/cyclic graphs or unsafe later recursion;
also bound bytes, depth/nodes, accepted result type, and traversal.
For nested/double serialization, byte-length corruption, and transformed-key
collisions, apply wp-metadata-api rather than blind string replacement.
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 anyunserialize()of binary blobs.
Fix: JSON for transport; reject serialized user input when possible; if
legacy parsing is unavoidable, constrain classes, bytes, depth, graph traversal,
and accepted result types; never accept phar:// from input on PHP 7.x.
2. SSRF in outbound requests
// HIGH — internal network probe / cloud metadata exfil
$response = wp_remote_get( $_POST['webhook_url'] );
Flag missing exact HTTPS host allowlists, plain wp_remote_* on
user-influenced URLs, unbounded timeout/redirect/body size, disabled TLS
verification, and ignored WP_Error/HTTP statuses. Use wp_safe_remote_* so
core validates the initial URL and redirects. Do not accept a hand-rolled DNS
pre-check as complete protection: it has rebinding/TOCTOU and IPv6 pitfalls.
Arbitrary destinations need infrastructure egress controls as well. Apply the
full wp-http-api-client skill for implementation and test patterns.
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.
// HIGH — GET with side effect, no nonce
if ( isset( $_GET['action'] ) && $_GET['action'] === 'delete' ) {
delete_post( (int) $_GET['id'] );
}
Rule: any cookie-authenticated browser handler that writes MUST verify a
nonce regardless of HTTP method. Legacy action links can be built with
wp_nonce_url( $url, 'delete_post_' . $id ) and verified with
check_admin_referer( 'delete_post_' . $id ); prefer POST forms for destructive
new UI. Signed webhooks, CLI, and cron use their own trust boundary rather than
a WordPress nonce.
4. Mass assignment
// 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
// HIGH — RCE
include $template_dir . '/' . $_GET['view'] . '.php';
locate_template( $_GET['t'] . '.php' );
Fix: allowlist:
$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
// HIGH — \r\n injection adds Bcc
wp_mail( '[email protected]', 'Hi', $body, "From: " . $_POST['email'] );
Fix: sanitize email + reject CRLF:
$from = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( ! $from || preg_match( '/[\r\n]/', $from ) ) {
wp_die( 'Invalid sender', 400 );
}
wp_mail( '[email protected]', 'Hi', $body, [ 'From: ' . $from ] );
Pass headers as an array, not a concatenated string.
7. ZipSlip / archive extraction
// HIGH — extracted file can escape with ../
$zip->extractTo( $target_dir );
Preferred WordPress path: initialize WP_Filesystem() and call core's
unzip_file(). Core validates each entry with validate_file(), calculates
required space, creates directories through the selected transport, and
returns true|WP_Error.
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( ! WP_Filesystem() ) {
return new WP_Error( 'filesystem_unavailable', 'Filesystem unavailable.' );
}
$result = unzip_file( $archive_file, $target_dir );
if ( is_wp_error( $result ) ) {
return $result;
}
This is not permission to unpack arbitrary uploads into a web-accessible
directory. Before extraction, impose compressed/uncompressed byte limits,
entry-count and extension/type policies; reject executable content when it is
not required. Extract to a fresh, non-public staging directory, inspect the
result, then move only expected regular files. unzip_file() skips invalid
paths but does not implement your product's content policy. For non-ZIP
formats or custom extractors, reject absolute/traversal paths, symlinks,
hardlinks, device nodes, and archive bombs, and containment-check every
destination before writing it.
8. Timing-safe comparison
// 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
// 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 only as a soft stampede hint. It is not an atomic correctness lock.
- For real exclusion:
$wpdb->query( "SELECT GET_LOCK('myplugin', 0)" )andRELEASE_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. Rate-limit abuse and global account lockout
A login defense can become an unauthenticated denial-of-service primitive. Flag code that lets failures supplied by one anonymous client create a global username/email lock which rejects the correct credential from every other client. A public username or email address is not proof that the account owner made the failed attempts.
Review these invariants:
- enforce blocking primarily on a bounded source/identity pair or source rate; use a global account signal for alerting, step-up checks, or delay rather than unconditional denial of a correct login from unrelated clients;
- enforce the same protection at every supported authentication entry point, not only a form-specific hook;
- clear the same identifier aliases that were incremented, including canonical username versus email login;
- prevent attacker-chosen nonexistent usernames from causing unbounded durable key creation or database writes;
- keep error responses from distinguishing an existing account from an unknown one;
- apply the atomic-counter guidance in the preceding TOCTOU section rather than assuming a transient read/modify/write is an exact limit.
Minimum acceptance test: after client A reaches the failure threshold for a known account, the correct credential from client B still succeeds while A remains throttled. Test every supported login surface with the same invariant.
This pattern can be HIGH when an anonymous caller can reliably and repeatedly deny a known user's correct login. State the required username knowledge, configured threshold, lock duration, and affected login paths.
11. Remote control-plane and executable response trust
Treat remote responses as attacker-controlled data even with valid HTTPS and a
fixed vendor. If response fields reach SQL execution, dynamic code/templates,
capabilities, redirects, or a private update package, read
reference.md
and trace the complete control-plane chain. Keep executable policy local and
versioned; accept only a small allowlisted ID plus schema-validated parameters.
Apply wp-http-api-client for transport, size, redirect, and test controls.
12. Direct file access guard
Top of every PHP file that has side effects on load:
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. A remote-control-plane chain that can
install PHP or execute unrestricted database statements can be CRITICAL when
its stated trigger is realistic. 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-auditfirst for the basic checklist (sanitize, escape, nonce, capability, SQL prepare). - Run
wp-security-secretsfor hardcoded credentials, weak randomness in tokens, and password-storage issues — these are adjacent but distinct findings. - Run
wp-batch-mutation-auditfor durable cursors, lost responses, retries, partial failures, and server-side exclusion.
What this skill does NOT cover
- Cryptographic protocol correctness (custom JWT, signing schemes).
- Business-logic IDOR beyond capability/ownership checks.
- Third-party dependency CVEs and manually bundled libraries; use
wp-dependency-security-auditbecausecomposer auditalone cannot inventory copied JS or manifest-less/prefixed PHP. - Batch retry/idempotency 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/
- Official documentation: https://www.php.net/manual/en/function.hash-equals.php