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.
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.