Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 01 / 09 30 MIN

The threat model

Trust Boundaries in a WordPress Site

Where the trust boundaries in a WordPress request actually sit, what crosses each one, and why a boundary nobody checks is an assumption rather than a boundary.

Ask a plugin author where the trust boundary is and you get a consistent answer: between the browser and the server. Ask what checks the data at that boundary and the answer gets vaguer, because in WordPress nothing does. Core parses a request into superglobals, routes the URL to a callback and hands the data over. The checks happen later, inside your code, and only if you wrote them.

The failure that follows is not exotic. A REST route for a shipment lookup is guarded with a login check. A settings page saves an option and verifies that the request came from the admin screen. A support tool passes a tracking number into the lookup and renders whatever comes back. Each of those is a boundary that was drawn on a diagram and never enforced in code, and each is one capability check away from being correct.

The definition below applies to any plugin. What you carry into a review is the list: the boundaries a WordPress request crosses whether or not you drew them, and what core uses to check each one. The list matters more than the definition, because most missing checks are missing for a duller reason than ignorance. Nobody wrote the boundary down.

A boundary is a handover, and the check is what makes it real

A trust boundary is a point where data moves between two parties with different interests. The browser is controlled by whoever is making the request. Your PHP process is controlled by you. Where they meet, a value stops being a value and becomes a claim about the world.

Two consequences follow, and both are easy to get wrong.

The check belongs on the side that receives the data. Validation that runs in the browser is a courtesy to the user, not a control, because the attacker is not using your form. The same is true of a check written in JavaScript before a REST call: the route is reachable without it. A boundary is real when the code on the far side looks at what arrived and decides whether to act on it.

Crossing a boundary changes where data came from, not what it means. A tracking number that arrives in a query string is untrusted text. Once you have checked its shape and looked it up, the shipment record that comes back is a different value with a different source, and it needs its own handling when it leaves your code again. Boundaries are crossed once per handover, not once per request.

The boundaries in a WordPress request are fixed and countable

WordPress requests pass through the same handovers regardless of the plugin set, which means you can inventory a plugin in an afternoon instead of reasoning from first principles every time.

BoundaryWhat crosses itWhat checks it in core today
Browser to server Method, path, query string, body, headers, cookies, uploaded bytes Nothing globally. Core fills the superglobals and every consumer checks its own slice.
Anonymous visitor to logged-in user Session cookie, credentials, application passwords wp_validate_auth_cookie(), wp_authenticate(), wp_set_current_user()
Logged-in user to capability holder Identity, roles and the capabilities attached to them current_user_can(), user_can(), map_meta_cap(); nonces from check_admin_referer() and wp_verify_nonce() cover intent
WordPress to the database SQL text and the values bound into it $wpdb->prepare() for values; %i for identifiers
WordPress to a third-party API URL, headers and request body out; status, headers and body back wp_safe_remote_get() and its siblings reject unsafe URLs. The response is input nobody has checked.
PHP to the filesystem Paths, file contents, uploaded bytes wp_handle_upload() with wp_check_filetype_and_ext(), sanitize_file_name(), WP_Filesystem, and the path constants in wp-config.php
Server to browser Rendered HTML, JSON, headers, cookies esc_html(), esc_attr(), esc_url(), wp_kses_post(), wp_json_encode()

Seven rows, and each is a place where a missing check is a bug rather than a policy choice. The rest of this course walks them in order: rows 1, 6 and 7 in module 2, row 3 in module 3, row 4 in module 4, and modules 5 and 6 for what to do when a row was crossed anyway.

Authentication answers who, and that is the whole answer

is_user_logged_in() is the check people reach for when they are thinking about boundaries, and it is the wrong check for nearly every decision. It answers one question: does this request carry a valid session for some user. It does not say which user, what that user may do, or whether the value in the request is one you can act on.

// Fragment of a fictional plugin. Not runnable on its own.
add_action( 'rest_api_init', static function () {
    register_rest_route(
        'tracking/v1',
        '/lookup',
        [
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'acme_tracking_lookup',
            'permission_callback' => static function () {
                return is_user_logged_in();
            },
        ]
    );
} );

function acme_tracking_lookup( WP_REST_Request $request ) {
    // Still attacker input: any registered account can send this.
    return acme_find_shipment( $request->get_param( 'number' ) );
}

With the default roles, the subscriber role holds read and nothing else, and a subscriber can log in. So a route guarded by a login check is open to every account on the site, including accounts created by checkout, by a membership plugin, or by an open registration form somebody enabled and forgot. The route returns another customer's shipment because the check tested whether a session existed rather than whether this user may see this object.

current_user_can() answers the second question. It reports whether the current user holds a capability, and it passes the request through map_meta_cap() first, which turns a question about a specific object into the primitive capabilities that object requires. That difference is most of module 3. The part that belongs here is narrower: neither function validates the tracking number, so a correct capability check still leaves $request->get_param( 'number' ) as arbitrary text from an authenticated attacker.

The role ladder is a capability list, not a ladder of trust

Subscriber, contributor, author, editor and administrator look like a hierarchy, and for the default roles they mostly are one. Reading them as a ladder of trustworthiness is where the reasoning breaks. An editor is not a smaller administrator. On a single-site install, an editor holds unfiltered_html, which means an editor can store HTML in post content that core will not strip when the content is saved.

Who actually holds unfiltered_html

The administrator and editor roles are both granted unfiltered_html when the roles are installed, and map_meta_cap() denies the capability to every user on multisite except a super admin. On single site, an editor can post markup that survives storage, so escape it at the point of output.

So data written by an editor is not safe to render for an administrator merely because the editor has a staff account. Push the argument one rung down and it gets sharper: a subscriber can be the source of data an editor later reads, and the editor's screen is where the payload runs. Anything a lower-privileged user can write is attacker input at the point where somebody with more access displays it, even when the write path checked everything it should have.

Validation and authorisation are separate checks on the same value

A fully authenticated request is still an attacker's request, because the attacker holds the credentials. Authorisation decides whether the caller may attempt the operation. Validation decides whether the value they sent is something you can act on. A boundary needs both, and neither substitutes for the other.

Consider the ways a tracking number can be wrong while every capability check passes. It can be absent, so get_param() returns null. It can be an array rather than a string, because a query string carries number[]= as easily as number=. It can be forty kilobytes of UTF-8, or contain a newline. Validation is the code that decides which of those are acceptable before the value reaches anything that assumes it is a tracking number. That is module 2.

Two boundaries people forget are boundaries at all

The rows that go missing from a threat model are rarely the browser-to-server one. The first that gets forgotten is the API response: data you did not send, from a service you do not control, arriving inside your PHP process through wp_remote_get() and then rendered or stored. The second is data written earlier by somebody with fewer privileges than the person reading it now, which is the same shape of problem with a longer delay between the write and the read.

wp_safe_remote_get() deserves a note because it is easy to misread as validation. It makes WordPress reject URLs pointing at private and loopback addresses, which limits server-side request forgery. It says nothing about the response. The body that comes back is attacker input in the same sense a form field is: the API can be compromised, replaced by a hostile DNS answer, or return the wrong shape.

The inventory is a table whose empty cells are the work

Write the boundaries down before you decide whether they hold. Four columns are enough: the handover, the data crossing it, the code that receives the data, and the check that runs there. A check that exists but tests the wrong property counts as an empty cell. A nonce proves intent and says nothing about permission, so a handler that verifies a nonce and never checks a capability has one empty cell with something written in it.

Boundary                    Data                        Receiver           Check
browser -> server           tracking_number (query)     REST callback      none
browser -> server           shipment note (POST)        order screen save  nonce only
browser -> server           api_key (POST)              settings page      current_user_can( 'manage_options' )
third-party API -> PHP      status JSON                 lookup handler     none
lower-privileged user -> PHP note text (database)        order screen       none
PHP -> database             shipment id in SQL          lookup query       $wpdb->prepare()
server -> browser           customer email in HTML      order screen       esc_html()

Two of those rows are empty in the sense that matters. The REST route is open to any account, and the carrier's status JSON arrives as trusted data because it came from a service the site pays for. An inventory that names both is already more useful than a scanner report, because it records what the scanner cannot know: which value the business cares about, and who is allowed to ask for it.

Lab 30 minutes

Take one plugin you maintain, or read the source of one you have installed. Write the four-column inventory from this lesson for it: handover, data, receiver, check. Aim for every place data enters or leaves the plugin, not every function.

Then mark each empty cell with one of three letters. A if an anonymous visitor can reach it, S if any logged-in account can reach it, and N if it needs a staff capability.

Answer two questions when the table is finished. Which empty cell is reachable with the account a customer gets at checkout? Which row contains a check that verifies intent rather than permission?

Show the solution and the reasoning

A shipping-tracking plugin with a lookup endpoint and a settings page produces something close to the table above, and the reasoning matters more than the exact rows.

The lookup route is the row people get wrong, and it is wrong for two independent reasons. If the permission callback is is_user_logged_in(), the route is reachable by every account on the site, so the row is marked S: a customer account can call it. The second reason survives even a correct capability check, because the tracking number is not validated. A route that requires manage_options and then passes 40 KB of text into a remote API call is authorised and still broken.

Boundary                    Data                        Receiver           Check                Reach
browser -> server           tracking_number (query)     REST callback      none                 A/S
browser -> server           shipment note (POST)        order screen save  nonce only           N
browser -> server           api_key (POST)              settings page      manage_options       N
third-party API -> PHP      status JSON                 lookup handler     none                 -
lower-privileged user -> PHP note text (database)        order screen       none                 -
PHP -> database             shipment id in SQL          lookup query       $wpdb->prepare()     -
server -> browser           customer email in HTML      order screen       esc_html()           -

The row reachable with a customer account is the lookup route, and it is reachable whether or not the route requires a login. That is the point of the letter column: it converts "this needs authentication" into "who has an account, and what does the route do for them".

The row with a check that verifies intent rather than permission is the order-screen save. A nonce confirms that the form was generated by the site for this user and that the request is not a cross-site forgery. It does not confirm that the user may edit the order. Change the order id in the form and a subscriber's request carries a perfectly valid nonce. The fix is a capability check on the order itself rather than on the screen, and it applies whether the nonce is present or not.

Two rows are unmarked because they are not reachable from outside the site. The carrier's JSON arrives because your code asked for it, and the shipment note arrives from a database write. Both are still boundaries. The API can be compromised or replaced, and the note was written by somebody with fewer privileges than the person now reading it. A row with no reachable attacker and no check has been accepted rather than secured, and the honest entry says which of the two it is.

If your inventory has fewer than five rows for a plugin of any size, you have listed the places you remember rather than the places data moves. Start again from the seven boundaries and follow the data out of each one.

Progress

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