Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 30 MIN

Security and language

Nonces, Capabilities and the Real Difference

The plugin verifies ten nonces and calls current_user_can 21 times. The two checks answer different questions, and sharing one nonce action throws that away.

An administrator loads the Couriers screen, the page includes a nonce, and a request carrying that nonce saves a company. A visitor loads the public tracking page, which also includes a nonce, and a request carrying that one looks up a shipment. Both requests are valid and both are checked. The interesting question is what happens if you give the two pages the same nonce action, which is the shortcut almost everyone takes once they notice that one helper function creates both values.

In the AJAX lesson you saw the two endpoints and their separate actions, and you saw a logged-in nonce refuse to validate for a logged-out user. This lesson is about the distinction the two checks in those handlers draw between them, because the plugin uses both on the same line of code and they are not interchangeable. It calls current_user_can( 'manage_woocommerce' ) at 21 sites, asks current_user_can( 'edit_shop_order' ) wherever there is an order to ask about, and verifies nonces explicitly at ten sites.

A nonce answers "did this come from us", and nothing else

The Plugin Handbook's nonce page uses the word "validated" for nonces and "authorised" for capability checks, and the two words are doing a lot of work. A nonce is a value WordPress can compute, given a user, a session, an action name and the current time window, and cannot be computed without the site's salts from wp-config.php. Seeing a correct one tells your handler that the request carries a value your code generated for that action. It tells you nothing about whether the person sending it should be allowed to do the thing.

A subscriber can load a page that prints a nonce and read it out of the HTML. If your handler verifies the nonce and stops there, the subscriber passes. The capability check is the part that refuses them.

The reverse holds too. A capability is a statement about a role, and on its own it says nothing about where a request came from. Without the nonce, any page on the internet can make a logged-in administrator's browser POST to your endpoint, and the browser will attach the session cookie because that is what cookies do. The capability check passes, because the administrator really is an administrator. The nonce is the part that refuses the drive-by.

wp_verify_nonce returns three values, and a truthiness check is still right

The return value is more informative than a boolean. Here is the whole of the decision in the 7.0.7 source, from wp-includes/pluggable.php:

// wp-includes/pluggable.php, WordPress 7.0.7, shortened
$i = wp_nonce_tick( $action );

// Nonce generated 0-12 hours ago.
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
	return 1;
}

// Nonce generated 12-24 hours ago.
$expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
	return 2;
}

return false;

So 1 means the nonce matches the current window, 2 means it matches the previous one, and false means neither. The plugin uses a plain truthiness test, as in this handler:

// includes/ajax.php, lines 67-76
case 'save_order_tracking':
	$order_id = isset( $_POST['order_id'] ) ? absint( wp_unslash( $_POST['order_id'] ) ) : 0;

	if ( ! $order_id || ! isset( $_POST['sfct_orders_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['sfct_orders_nonce'] ) ), 'sfct_save_order_from_orders_page_' . $order_id ) ) {
		wp_send_json_error( array( 'message' => __( 'The request could not be validated.', 'sifero-courier-timeline-for-woocommerce' ) ), 403 );
	}

	if ( ! current_user_can( 'edit_shop_order', $order_id ) && ! current_user_can( 'manage_woocommerce' ) ) {

! wp_verify_nonce( ... ) accepts both 1 and 2, and that is correct. There is no fourth return value, so nothing falsy-but-valid exists to slip through: the function returns false on the failure path and a positive integer on the two success paths. I built a nonce for each tick against a real 7.0.7 install and verified all three:

verify nonce built for tick i    (expect 1) = 1
verify nonce built for tick i-1  (expect 2) = 2
verify nonce built for tick i-2  (expect false) = false
verify nonce_i with a different action (expect false) = false
verify nonce_i as logged-out user (expect false) = false

You will meet advice to reject 2 on the grounds that it is stale. That is a different decision from a correctness one, and it comes from confusing the return value with the lifetime. 2 is inside the lifetime WordPress intends, not past it. Treating 1 and 2 differently means a form submitted two minutes after midnight refuses a request that would have worked two minutes earlier, for no security gain.

The tick is half the lifespan, and the interval is the point

The window comes from wp_nonce_tick():

// wp-includes/pluggable.php, WordPress 7.0.7
function wp_nonce_tick( $action = -1 ) {
	$nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );

	return ceil( time() / ( $nonce_life / 2 ) );
}

Read the division carefully, because it is where the 12 and the 24 come from. DAY_IN_SECONDS is 86,400, the filter supplies a lifespan, and the tick number is the clock divided by half that lifespan. So a tick is 12 hours long by default. wp_create_nonce() stamps the current tick and wp_verify_nonce() accepts the current tick or the one before it, which is why a nonce created at an arbitrary moment lives between 12 and 24 hours. The filter is real and it is a lifespan in seconds, not a tick number; setting it to 120 gives you one-minute windows, which I confirmed on the lab install:

wp_nonce_tick() with nonce_life = 120 (tick length 60s) = 29869703
ceil( time() / 60 ) for comparison                     = 29869703

Two consequences follow. First, "nonce" is a bad name and an old one: it is a number used once in the sense that it should not be reused across identical requests, and it is reused all day. Second, its lifetime is a site-wide setting, so it cannot be the only thing between an attacker and a destructive action.

The other three ingredients explain who can use a given nonce. $uid is the current user id and $token comes from wp_get_session_token(). A logged-out visitor has $uid of 0 and an empty token, so their nonces are a function of the tick, the action and your salts alone. I checked both directions against the plugin's two action names and neither validates for the other audience:

logged-out verify of admin nonce, same action   = false
logged-in verify of logged-out nonce, same action = false

Sharing one action across two audiences is a bug about intent

The plugin keeps two action names for its two AJAX surfaces. includes/assets.php mints sfct_admin_nonce for the admin script and sfct_public_nonce for the frontend script, and each handler verifies the one that matches its audience:

// includes/ajax.php, line 13 and line 212
check_ajax_referer( 'sfct_admin_nonce', 'nonce' );
// ...
check_ajax_referer( 'sfct_public_nonce', 'nonce' );

Those are two of roughly fifteen distinct nonce action strings in the plugin. The rest are single-purpose: sfct_save_order_from_orders_page_{$order_id}, sfct_template_preview, sfct_upload_template and a handful of one-off notice checks. Exactly one of the fifteen is minted for, and verified from, an unauthenticated request. That is the shape you want, and the reason is not that a public token would let an attacker into the admin endpoint, because the user id and session token in the hash prevent that. It is that the action name is the only thing in the code recording who a token was for.

Collapse the two names into one and three things happen at once. The logged-out branch of your code starts minting tokens for the same action string an authenticated handler verifies, and on a site with a weak or duplicated salt, a token that depends only on the tick, the action and the salt is a token an attacker can compute without an account. The public page's token is printed into HTML served to every visitor, so it is a secret with thousands of holders. And the next developer reading the admin handler sees a nonce action they can also reach from the front end, with nothing to tell them the two are not the same thing. The capability check still saves that handler today; it is the next handler, written by someone who trusted the pattern, that the shared name endangers.

Nonce first, capability second, both required

The order in the plugin's admin handler is not arbitrary:

// includes/ajax.php, lines 12-19
function sfct_ajax_admin_action() {
	check_ajax_referer( 'sfct_admin_nonce', 'nonce' );

	if ( ! current_user_can( 'manage_woocommerce' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have sufficient permissions.', 'sifero-courier-timeline-for-woocommerce' ) ), 403 );
	}

	$subaction = isset( $_POST['subaction'] ) ? sanitize_key( wp_unslash( $_POST['subaction'] ) ) : '';

check_ajax_referer() defaults its third argument to true, which means it does not return on failure: it calls wp_die( -1, 403 ) and the request is over. So the nonce check comes first because it is the cheapest rejection and the one you want firing before any of your code runs, and because the two failures deserve different answers. A failed nonce gets -1 and no body to display, since there is nothing useful to say to a request that may not have come from your page. A failed capability gets 403 with a message the admin screen can show, because the person on the other end is a logged-in user you can talk to.

Ordering the other way is not a security hole in this handler, but it is a mistake with consequences. Your capability check runs on a request you have not yet established came from your own page, so every failed nonce attempt reaches code that reads $_POST. Pull the subaction and a couple of ids out of the request before you know the request is yours, and you have made an unauthenticated request do work. Get into the habit of doing validation first and authorisation second and the habit holds when the handler is expensive.

Both checks are mandatory, in every handler, at every point where a request can enter. The plugin has one place where it does something slightly different and worth reading, the object-level check in the orders handler. It verifies a per-order nonce whose action string includes the order id, then asks whether the current user may edit that order:

// includes/ajax.php, line 70 and line 74
'... ' . 'sfct_save_order_from_orders_page_' . $order_id
// ...
if ( ! current_user_can( 'edit_shop_order', $order_id ) && ! current_user_can( 'manage_woocommerce' ) ) {

Two details there are better than the shared nonce above them. The id in the action name binds the token to one order, so a captured request cannot be replayed against a different one, and edit_shop_order is a meta capability: passing the order id makes WordPress map it to the capability for that specific post type and that specific object, which is a different question from "does this user edit shop orders in general".

The same handler set contains the opposite case, and it is the one to read with a pencil. generate_tracking begins at line 101, takes an order_id from $_POST, and is guarded only by the shared admin nonce and manage_woocommerce at the top of the function. There is no edit_shop_order check on that order, so a user who holds the store-wide capability but not the order can generate tracking data onto it. On most stores manage_woocommerce is held by administrators, so nothing breaks; the moment someone hands that capability to a fulfilment role, it does. The fix is three lines copied from the case above it, and the reason to copy them is that the two subactions act on the same object and should therefore ask the same question about it.

Lab 30 minutes

Working from a copy of the plugin, audit every place a request can enter and produce a table with one row per site: file, line, the nonce action, the capability, and whether the order in the source is nonce then capability. The entry points are the two AJAX handlers, the admin_post_ handlers, and the handlers that run on admin_init.

Then answer three questions about what you found. Which entry points verify no nonce at all, and for each one say whether the omission is safe and why. Which ones check a capability but not a nonce. Does any handler read $_POST before both checks have passed.

Finish by fixing generate_tracking so it asks the same object-level question as save_order_tracking, and write one sentence saying what a fulfilment-role user could do before your change and cannot do after it.

Show the solution and the reasoning

Three kinds of entry point exist in this plugin, and they fail differently.

The two AJAX handlers are guarded. sfct_ajax_admin_action opens with check_ajax_referer( 'sfct_admin_nonce', 'nonce' ) and then manage_woocommerce, which is the correct order, and every destructive subaction inside inherits both. sfct_ajax_public_lookup opens with check_ajax_referer( 'sfct_public_nonce', 'nonce' ) and has no capability check, which is the right call: it is a public lookup, and the thing that protects the data is the tracking id being unguessable and sfct_get_tracking_payload() returning a payload only for a matching order. The nonce there stops other sites driving the endpoint; it cannot be the access control.

The admin_post_ handlers include sfct_handle_create_tracking_page in includes/shortcodes.php, which does manage_woocommerce at line 39 and check_admin_referer( 'sfct_create_tracking_page', 'sfct_create_tracking_page_nonce' ) at line 43. That is a capability check followed by a nonce check, the reverse of the AJAX handlers, and it is safe because check_admin_referer() also dies on failure. The pattern that matters is not the order but that no request data is consumed before both checks have run. sfct_upload_template_file in includes/templates-frontend.php follows the same shape, capability at line 879 and check_admin_referer at line 883.

Twelve functions hold a capability check and no nonce verification, and every one is read-only or a predicate: ten page renderers and redirect helpers in admin-pages.php and the bootstrap file, the two sfct_rest_can_read and sfct_rest_can_update permission callbacks in includes/rest-api.php, and sfct_shortcode_current_user_can_view_order in shortcodes-core.php. A GET that renders a settings screen changes nothing, so there is no intent to prove, and a REST permission callback is the wrong place for a nonce check: the REST API carries authentication differently, and you will meet that properly in the REST lesson.

What you are looking for instead is a function that changes state on a capability alone, and the audit finds one: generate_tracking. The other state-changing handler, sfct_handle_orders_page_save in includes/orders.php, verifies a per-order nonce at line 604 and then edit_shop_order at line 609, so it belongs in the guarded group.

The one place the plugin reads request data first is sfct_handle_orders_page_save, which resolves $_POST['order_id'] before it verifies anything, because the nonce action string it needs is built from that id. You cannot check a nonce whose action name contains a value you have not read yet, so the read is unavoidable there; what matters is that the value is put through absint() on the way in and used only to build the action name and the redirect, never to touch an order, until both checks have passed.

The fix copies the object-level check from the case above it:

// includes/ajax.php, inside case 'generate_tracking':, after $order_id is read
$order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;

if ( ! $order ) {
	wp_send_json_error( array( 'message' => __( 'Order not found.', 'sifero-courier-timeline-for-woocommerce' ) ), 404 );
}

if ( ! current_user_can( 'edit_shop_order', $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
	wp_send_json_error( array( 'message' => __( 'You do not have permission to edit this order.', 'sifero-courier-timeline-for-woocommerce' ) ), 403 );
}

The handler already resolves the order a few lines further down, so the tidy version moves that lookup above the capability check rather than duplicating it. Resolve the object, then ask about the object: you cannot pass an order id to a capability check before you have one.

The sentence the lab asks for is the whole justification, and it needs to be concrete. Before the change, a user holding manage_woocommerce but not edit_shop_order on someone else's order can call generate_tracking with that order's id, mint a tracking id, write _sfct_tracking_generated_at and _sfct_tracking_registered_at onto the order, and create a log entry. After the change the second check refuses the request, while administrators are unaffected because they hold the store-wide capability and the condition is an OR.

One habit worth taking from the audit: the shared sfct_admin_nonce does less work than it looks like it does. It proves the request came from an admin page in the last day, which is real, but it is one string covering every subaction in a switch with fourteen cases. The per-order action name is what narrows a token to a single object, and the action name should name the action.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.