WordPress plugin security¶
wordpress-plugin-security
Review and implement WordPress plugin security: capabilities, nonces, sanitization, escaping, prepared SQL, and REST permission callbacks. Use when adding admin screens, AJAX, uploads, hooks, or REST routes in a WordPress or WooCommerce plugin.
Version 0.3.2 · MIT · Source · Rule
Install this skill
Copy the skill and its rule into your project:
The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.
Apply this skill to every new entry point: admin form, admin-ajax, admin-post, REST route, Store API extension, file upload, and any hook that reads $_POST, $_GET, or $_REQUEST.
WooCommerce data access is covered by the WooCommerce plugin skill. This skill is the authorization and input/output layer around it.
Procedure¶
- Name the actor and the capability. Check
current_user_can()with a capability, not a role name. WooCommerce capabilities includemanage_woocommerce,edit_shop_orders,edit_products, andedit_shop_coupons. - For a state change that uses a cookie session, require a nonce. Nonces stop cross-site request forgery. They do not identify the user and they expire.
- Sanitize input into a known type, then pass that value to CRUD or to
$wpdb->prepare(). Escape at the point of HTML, attribute, or URL output. - Give every REST route a real
permission_callback.__return_trueis only acceptable for data you would publish to anonymous visitors. - Fail closed. A missing order, a failed nonce, or a missing capability returns without writing.
Use the review checklist before finishing a change.
Forms and admin-post¶
function example_save_desk_note() {
if ( ! current_user_can( 'edit_shop_orders' ) ) {
wp_die( esc_html__( 'You cannot edit orders.', 'example-plugin' ) );
}
check_admin_referer( 'example_order_panel', 'example_order_nonce' );
$order = wc_get_order( absint( wp_unslash( $_POST['order_id'] ?? 0 ) ) );
if ( ! $order ) {
wp_die( esc_html__( 'Order not found.', 'example-plugin' ) );
}
$note = sanitize_text_field( wp_unslash( $_POST['desk_note'] ?? '' ) );
$order->update_meta_data( '_desk_note', $note );
$order->save();
}
add_action( 'admin_post_example_save_desk_note', 'example_save_desk_note' );
Unslash, then sanitize. wp_unslash() reverses magic quotes behavior that WordPress still normalizes for. Cast ids with absint(). Allow limited HTML only with wp_kses() or wp_kses_post() against an explicit allow list.
AJAX¶
- Logged-in actions use
wp_ajax_{action}. - Anonymous actions use
wp_ajax_nopriv_{action}and must not trust the visitor with shop data. - Verify with
check_ajax_referer(). - Return JSON with
wp_send_json_success()orwp_send_json_error(). Do not echo SQL, file paths, or secrets in the error payload.
REST routes¶
register_rest_route(
'example/v1',
'/orders/(?P<id>\\d+)/desk-note',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => 'example_rest_save_desk_note',
'permission_callback' => static function () {
return current_user_can( 'edit_shop_orders' );
},
'args' => array(
'id' => array( 'sanitize_callback' => 'absint' ),
'desk_note' => array( 'sanitize_callback' => 'sanitize_text_field' ),
),
)
);
The permission callback must authorize the object, not only the route. A user who can edit_shop_orders may still be blocked by a custom order cap. Check the specific order when the shop uses per-order restrictions.
Do not accept a user id, order id, or price from the client as proof of identity. Load the object and derive the price on the server.
SQL¶
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT id FROM ' . $wpdb->prefix . 'wc_orders WHERE billing_email = %s',
$email
)
);
Pass every variable through prepare(). Do not concatenate table names from request data. Hard-code a known suffix and prefix it with $wpdb->prefix. Prefer wc_get_orders() for order lookup. The SQL above is only a pattern for a report you have already decided must run against the HPOS orders table.
Uploads and files¶
- Use
wp_handle_upload()and check the returned error. - Allow a short extension list. Do not trust the browser MIME type alone.
- Store uploads outside executable locations when they are not meant to be public. Never use a request parameter as a filesystem path.
includeandrequireonly files whose paths are built from constants you control.
Gotchas¶
check_ajax_referer()dies on failure by default. That is fine for AJAX. For a REST request, return aWP_Errorwith status 403 instead of dying.- Nonces in HTML must be created for the same action string you later verify. Copying a WooCommerce nonce from another screen will fail, and it should.
current_user_can( 'administrator' )misses shop managers who legitimately havemanage_woocommerce, and it matches the wrong concept. Check the capability.esc_html()does not make a URL safe. Useesc_url().esc_attr()does not make HTML body text safe. Escape for the context you are in.unserialize()on stored or posted data can instantiate objects. Store JSON, or usemaybe_unserialize()only on data your own code wrote, and prefer JSON for new meta.- A hidden form field such as
order_idis attacker-controlled. Capability checks happen after you load that order, not instead of loading it. - Logging
$_POSTon a checkout request can capture payment fields. Log ids and your own error codes.
Verification¶
- Repeat the write while logged out. It does not change data.
- Repeat the write as a subscriber or customer. It does not change shop data.
- Repeat the write with the nonce removed. It does not change data.
- A REST callback with the permission callback removed is a failed review, even if the happy path works.
- An error response does not include a secret, a stack trace, or another customer's data.
Evaluation¶
Worked examples and the checks they must satisfy are in evals/tasks.json.
References¶
Plugin security review¶
Check each new entry point. Skip a line only when it does not apply, and say why.
Entry¶
- The file starts with
defined( 'ABSPATH' ) || exit;or is loaded only from a file that does. - Admin-post, admin-ajax, REST, and form handlers each have their own capability check.
- State-changing browser requests verify a nonce bound to that action.
- The REST
permission_callbackis a named function or closure that returns a capability check, never__return_true, for private data.
Input¶
- Request values go through
wp_unslash()and then a sanitizer:absint,sanitize_text_field,sanitize_email,esc_url_raw,wp_kses_post, or a strict allow list. - Prices, quantities, and statuses are validated against server-side rules. The client amount is not stored as the charged amount.
- Sort columns, table names, and file paths are chosen from a fixed map, not from the request string.
- Uploads use
wp_handle_upload()and an extension allow list.
Output and storage¶
- HTML text uses
esc_html(). Attributes useesc_attr(). URLs useesc_url(). Translated strings use the escaping gettext variant. - SQL variables use
$wpdb->prepare(). - New meta and options are JSON or scalars. No
unserialize()of request data. - Secrets and card data are not written to logs, order notes, or the browser.
WooCommerce-specific¶
- Order and product writes use CRUD.
- The handler works for HPOS when it touches orders.
- Checkout logic that must run for blocks is hooked to the Store API, not only to classic checkout.
- Webhook handlers verify a signature instead of using a nonce. Nonces are for cookie sessions. Webhooks are not cookie sessions.
Evaluation¶
Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.
private-rest-route¶
Register a REST route that updates an order note and rejects shoppers who cannot edit orders.
Must include: permission_callback, current_user_can, edit_shop_orders
Must not include: __return_true
Worked example
Register the route with a real permission callback.
Sanitize the note and load the order on the server before writing.
admin-form-nonce¶
Save an admin form that changes an order only when the user has the capability and a valid nonce.
Must include: check_admin_referer, current_user_can
Must not include: unserialize(
Worked example
Verify the user and the nonce, then sanitize the field.