The public side
REST Routes for Couriers and Trackers
Three routes, one namespace, and a permission callback that does the same work twice. What the routes accept, what they refuse, and why none of them is public.
Read includes/rest-api.php from top to bottom and you will be done in
four minutes. It is 143 lines and most of them are callbacks. The routes themselves are
about sixty lines, and the interesting part is not how many there are. It is that the file
is describing two audiences, couriers and trackers, and the permission code does not
distinguish between them at all.
That mismatch is easy to miss in a code review because both callbacks are named well.
sfct_rest_can_read() and sfct_rest_can_update() read like a
permission boundary. Open them side by side and they are the same five lines.
Three routes under one namespace
sfct_register_rest_routes() registers everything in one function on
rest_api_init, and every route shares the namespace
sfct/v1. The version suffix is not decoration: a route path is a public
contract, and changing the shape of a response is a breaking change. Adding
v2 lets both live at once while clients migrate.
| Route | Methods | Permission callback | Callback |
|---|---|---|---|
/sfct/v1/tracking/(?P<id>[A-Za-z0-9\-\_]+) | READABLE (GET) | sfct_rest_can_read | sfct_rest_get_tracking |
/sfct/v1/status/(?P<id>[A-Za-z0-9\-\_]+) | READABLE (GET) | sfct_rest_can_read | sfct_rest_get_status |
/sfct/v1/update/(?P<id>[A-Za-z0-9\-\_]+) | CREATABLE (POST) | sfct_rest_can_update | sfct_rest_update_status |
The method constants come from WP_REST_Server, and in WordPress 7.0.7
READABLE is the string 'GET' while CREATABLE is
'POST'. Using the constants rather than the strings is worth the small extra
typing, because EDITABLE is not a synonym for anything you would guess: it is
'POST, PUT, PATCH' as a single comma-separated string, which the server
splits. A route that wants to accept updates by any of those verbs declares
EDITABLE and inspects the method in the callback.
You can read the registrations back out of a running site instead of trusting the table, which is also how you find out what another plugin has already claimed:
wp eval 'foreach ( rest_get_server()->get_routes() as $route => $handlers ) {
if ( 0 === strpos( $route, "/sfct/v1" ) ) { echo $route, "\n"; }
}'
Both permission callbacks do the same thing
Here is sfct_rest_can_read(), unedited:
function sfct_rest_can_read( WP_REST_Request $request ) {
if ( current_user_can( 'manage_woocommerce' ) ) {
return true;
}
$settings = sfct_get_settings();
$token = $request->get_header( 'x-sfct-token' );
return $token && ! empty( $settings['api_token'] ) && hash_equals( $settings['api_token'], $token );
}
And here is sfct_rest_can_update(), unedited:
function sfct_rest_can_update( WP_REST_Request $request ) {
if ( current_user_can( 'manage_woocommerce' ) ) {
return true;
}
$settings = sfct_get_settings();
$token = $request->get_header( 'x-sfct-token' );
return $token && ! empty( $settings['api_token'] ) && hash_equals( $settings['api_token'], $token );
}
Neither function is public. A route is reachable without a session only when its
permission_callback returns true on an anonymous request, and
this one returns a boolean expression that is false for an empty token. Probe it on a
local install and watch the status codes:
wp eval '
wp_set_current_user( 0 );
$r = new WP_REST_Request( "GET", "/sfct/v1/tracking/SFCT-2026-000001" );
$res = rest_do_request( $r );
echo $res->get_status(), " ", $res->as_error()->get_error_code(), "\n";'
401 rest_forbidden
Add the header and the same request moves to the route callback and returns the
payload. With no record in the table it returns 404 not_found after the
permission check has already passed, which is the difference between "you may not ask" and
"there is nothing to find". Collapsing a failed permission check into a 404 would hide a
misconfigured token behind an innocent-looking answer.
Two details in the shared expression are deliberate. hash_equals()
compares in constant time, so an attacker cannot learn the token byte by byte from
response timings; a plain === would leak that. And the
! empty( $settings['api_token'] ) guard means a site that never set a token
cannot be authenticated into, because there is nothing non-empty to match. Take that
second part out and an unset token plus no header can compare equal to itself.
Undeclared parameters are not sanitised for you
The REST server validates and sanitises the arguments a route declares in
args. Anything else in the request is passed through untouched.
status and comment are not in this route's schema, so the
callback reads them with get_param() and does its own
sanitize_key() and sanitize_textarea_field(). That is currently
correct, and it is the kind of correctness that breaks the moment someone adds a field
and assumes the framework cleaned it.
The regex in the route argument narrows the input early
The capture group on all three routes is (?P<id>[A-Za-z0-9\-\_]+).
WordPress matches route patterns against the request path before any callback runs, so an
ID containing a space, a dot, a slash or a percent-encoded byte never reaches the
permission check. It gets a 404 from the router. A request for
/sfct/v1/tracking/abc%20def returns rest_no_route, not
rest_forbidden, which is a useful distinction when you are debugging: the
route was not found at all.
That is a real defence and it is a narrow one. It stops the obvious path traversal
attempt, because ../ cannot match: dot and slash are both outside the class.
It allows every value the plugin itself generates, which is why the hyphen and underscore
are in there to keep the generated SFCT-2026-000001 shape valid. What it does
not do is validate the ID. AAAAAAAA matches the pattern and is not a tracking
record; the callback answers 404 not_found after a database lookup. And the
pattern is not a length limit, so a two-kilobyte run of letters still gets as far as
sfct_get_tracking_payload(). If you write this yourself, put a
maxLength in the schema as well, or the regex is the only thing between a
curious client and a query with a very large string in it.
The args schema is small on purpose
Each route declares exactly one argument:
'args' => array(
'id' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
required is almost redundant here, since a URL that does not match the
pattern does not match the route. Its value is documentation: the route index at
/wp-json/sfct/v1 and the block editor's data layer both read it. The
sanitize_callback is doing more work than it looks, because
sanitize_text_field() strips tags and collapses whitespace and newlines, so a
crafted ID that somehow reached the handler arrives as one clean line before
sfct_find_order_by_tracking_id() sees it.
The update route reads two more parameters that its schema does not mention.
sfct_rest_update_status() pulls status and reads
comment, validates the status against
array_keys( sfct_get_logistics_statuses() ) and returns a
400 invalid_status for anything else. Undeclared parameters cannot be
validated by the framework, cannot be discovered through the route index, and do not
appear in generated API documentation. Declaring them costs a dozen lines and moves the
validation into the place every REST client already knows to look.
A registered route is irreversible in the request that registered it
There is no unregister_rest_route() in WordPress 7.0.7. I checked the
source rather than trusting memory:
grep -rn "function unregister_rest_route" wp-includes/ wp-admin/
That command prints nothing. The only counterweight is the fourth parameter of
register_rest_route( $route_namespace, $route, $args, $override ), which
replaces the handlers for a route someone else already registered under the same path.
You cannot remove a route, you can only take it over, and taking it over means the original
callbacks no longer run at all.
What that means in practice is that deactivating the plugin is the only way to remove
/sfct/v1/tracking/… from the site. While the plugin is active the route is
registered on every request, and any code that later wants a different permission model
has to override it. This is the strongest argument for the shared permission callback
being a mistake: if you want to open the read routes to anonymous trackers later, the
change is not additive. Every client that already sends the token keeps working, and every
client that did not now reads order data it could not read before, on a route whose name
already promises public tracking.
On your local install, with a courier company and one order that has a tracking ID,
exercise all three routes with rest_do_request() or curl and
write down the status code and error code for each case:
GET /sfct/v1/tracking/<id>with no authentication.- The same request with
X-SFCT-Token: anythingbefore you have saved a token in the settings. - The same request with the correct token after saving one.
POST /sfct/v1/update/<id>with the correct token and a status that is not one the plugin knows.GET /sfct/v1/tracking/abc%20def.
Then answer two questions. First, sfct_rest_can_read() and
sfct_rest_can_update() are identical. Is the update route actually more
protected than the read routes, and what would you change? Second, a courier wants to
poll /sfct/v1/status/<id> from a server with no WordPress session.
Write the smallest change that lets them, and say what it makes possible for anyone
else who knows a tracking ID.
Show the solution and the reasoning
The five answers, from a WordPress 7.0.7 install with the plugin active:
1. 401 rest_forbidden (permission_callback returned false)
2. 401 rest_forbidden (no token saved, so nothing to match)
3. 200 with the payload (or 404 not_found if the id has no record)
4. 400 invalid_status (permission passed, callback rejected the value)
5. 404 rest_no_route (the regex never matched, so no route exists)
Case 5 is the one people get wrong. It is not a rejected request, it is an unknown URL, and that is the regex doing its job before any of your code runs. Case 4 is the other useful pair: the permission callback passed and the route callback refused, which is exactly the separation you want, because it means an authenticated caller gets a specific reason instead of a generic denial.
On the first question: no, the update route is not more protected in any way that
matters. Both callbacks admit the same two classes of caller, an administrator and
anyone holding the single site-wide token. The difference between reading a tracking
status and rewriting it, logging a new timeline entry, and saving order meta is real, and
the code does not acknowledge it. The fix is not a copy of the read callback with a
different name. It is either a separate token scoped to writes, or a capability check
that is not manage_woocommerce, or, at minimum, one shared function so the
two callbacks cannot drift apart. Three functions that are textually identical is the
shape of a bug that arrives later.
On the second question, the smallest change that does not open the write path is to give the read routes their own callback:
function sfct_rest_can_read_public( WP_REST_Request $request ) {
// Anyone may read a status by tracking ID; the ID itself is the capability.
return true;
}
That is one line and it works, and the cost is that the tracking ID becomes a bearer
credential. It already is, in effect: the public tracking page accepts the same ID with
no login, so hiding it behind a token while the page shows the same timeline to anyone
who types the code buys nothing except the appearance of security. If you make the read
routes public you should say so in the response and in the docs, and you should decide
deliberately whether the payload's order_number and customer instructions
belong in an unauthenticated response. A tracking ID is a low-entropy secret that people
paste into chat windows and support tickets. Treating it as a credential is a decision,
not a default.
Whatever you decide, decide it before you ship. There is no
unregister_rest_route() in WordPress 7.0.7, so a route you registered with
__return_true stays that way for as long as the plugin is active, and the
only way back is a release that changes the permission callback for every site at once.
Make the read routes public if that is the product, and keep the write route behind a
token that is stored nowhere near the read path.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.