Skip to content
System nominalUTC --:--:--Articles 041

Threat Modelling a WooCommerce Plugin in Ninety Minutes

Four questions, seven trust boundaries and a table you can fill in as a team. What a ninety-minute threat model finds, and the class of bug it will never find.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Topic
SEC
Section
Security & Hardening
Published
28 APR 2026
Reading time
9 MIN
Words
2486W
Technical cover graphic for Threat Modelling a WooCommerce Plugin in Ninety Minutes
Generated schematic — SEC series, REV 2.6.

A threat model that runs for a full day and produces a document is a threat model nobody uses. The usual failure is that it describes the system instead of the attacker: it lists components, restates the well-known vulnerability categories, and closes with a recommendation to validate input, which is not a decision anybody can act on.

The version that works is smaller: four questions, one whiteboard, ninety minutes, and a list of concrete changes with names attached. It is not a security assessment and does not replace one. It is the pass before the assessment, so the assessment spends its budget on the parts you could not see yourself.

Applied to a WooCommerce extension the exercise is unusually productive, because this kind of plugin has all the boundaries at once: a public storefront, a customer who owns exactly one order, staff with real authority, a database holding personal data, and an outside courier API that both directions of trust run through.

Four questions, and the hour and a half is the budget

The four questions are the ones most threat modelling methods converge on. They are worth writing on the board, because each has a different failure mode when you skip it.

  • What are we building? Answer with data flow, not features. What enters, what leaves, what is stored, where it is stored, and which of those things is trusted. A whiteboard diagram with arrows is enough. Boxes without arrows hide the boundary you are looking for.
  • What can go wrong? Walk each boundary and ask what the other side controls. This is where the time goes.
  • What are we going to do about it? Every item ends as mitigate, accept, transfer or remove, with a name next to it. An item with no decision is a wish.
  • Did we do a good job? Read the list back. Every boundary should carry at least one entry, and every entry needs an owner and a trigger. A boundary that produced nothing is either genuinely dull or nobody in the room knew enough about it.

Budget the ninety minutes as twenty, thirty, twenty-five and fifteen. The last fifteen are the ones teams cut, and they are the ones that decide whether the other seventy-five were real.

Two rules keep the room honest. Findings describe the system rather than the author, because "somebody should check the webhook" is not a finding. And nobody rates anything high, medium or low until the end; a score assigned in the moment avoids the harder conversation about which asset is at stake.

The trust boundaries of a WooCommerce extension

A trust boundary is anywhere two parties with different interests exchange something. A store plugin has seven worth naming. For each, write down what crosses it, what the other side controls, and the worst outcome that is still realistic.

BoundaryWhat crosses itWhat the other side controlsWorst realistic outcome
Unauthenticated visitorsQuery strings, form posts, REST callsEvery parameter in the requestEnumerating order data, or forcing work on every request
Logged-in customersTheir own order, addresses, tracking linksTheir account session and any identifier in their requestsReading another customer's order through a guessed ID
Shop managersOrder status, refunds, shipment recordsAny order in the storeFulfilment records altered with no audit trail
AdministratorsSettings, API credentials, code pathsThe whole installA stolen admin session, or code that assumes an admin is honest
The databaseEvery row the plugin reads or writesNothing, until the plugin's own query is shaped by inputCustomer data exposed by a query the plugin assembled
Courier APIsStatus in, order and address data outThe response body, and when it arrivesStored responses treated as markup, or an outage that stops checkout
The file systemLogs, exports, cached API responsesAnything written into a web-readable pathA log or an export served over HTTP with addresses in it

Unauthenticated visitors are the boundary teams misjudge, usually by treating a complicated URL as a barrier. It is not one. Anything reachable without a session is reachable by every scraped address list on the internet, so the controls that matter hold without knowing who is calling: a rate limit, an opaque token instead of a sequential identifier, and no personal data in the response.

Logged-in customers are authenticated and authorised for nothing except their own records. Almost every serious finding in a store plugin sits here, because a session check is easy to write and an ownership check is easy to forget. A handler that verifies a session and then trusts an order ID from the request has verified the wrong thing.

Courier APIs carry trust in both directions, which is why they produce two classes of problem. Inbound, the response is data from outside your control, so treat it as untrusted: an error body reflected into a customer's page is a scripting problem dressed up as a logistics one. Outbound, the credential lives on your side, and a lookup endpoint any visitor can trigger hands that credential to whoever finds it, right up until the courier starts rate limiting the store.

The database is not an attacker, and it is where a missing boundary becomes permanent. Escaping is the question people ask first. The question that matters more is whether a request can choose which rows come back, and that one has no escaping answer.

STRIDE as a checklist, not a vocabulary test

The six STRIDE categories are useful prompts and unusable presentation. Spoofing, tampering, repudiation, information disclosure, denial of service and elevation of privilege turn into ordinary questions once you stop saying the words out loud.

Spoofing
Can somebody be something they are not? A webhook with no signature check, a tracking link anybody can guess, a header that claims an IP address.
Tampering
Can somebody change data they should not? A posted order ID, a status field in a form, an export file written somewhere the web server can rewrite it.
Repudiation
Can somebody deny an action afterwards? If a shipment status changes and nothing records who changed it, the answer is yes.
Information disclosure
Can somebody see data they should not? This is where customer personal data lives: addresses, emails, phone numbers, order contents.
Denial of service
Can somebody make the store unavailable or expensive? An unauthenticated endpoint that calls a rate-limited courier API is a billing problem and an availability problem at the same time.
Elevation of privilege
Can somebody do more than their role allows? Usually a missing capability check, or a capability chosen far too loosely for the operation.

Walk the list at the boundaries that hold something worth taking, not at all seven. Customer data and money are where the categories earn their keep. The file system boundary usually yields one finding about exports and one about permissions, and then it stops.

The acronym also compresses an argument: "that is a repudiation problem" gets a room to the point faster than a paragraph of explanation would.

Prioritise by asset: customer data, order integrity, availability

Severity scores are hard to assign in a room and easy to argue with a week later. Assets are concrete, and a WooCommerce extension has three that matter.

Customer personal data comes first because the cost lands outside the code. Addresses, emails and phone numbers collected for delivery are what an attacker wants, and losing them means notifications, a legal question and customers who do not come back. A finding that puts personal data in front of the wrong person outranks everything else on the list.

Order integrity comes second because the cost is money and it lasts. An order marked shipped that never shipped, a refund recorded twice, a tracking number attached to the wrong parcel: none of these leak data, and all of them take staff hours to unwind. Damage to integrity also destroys the trail you would need to work out what happened.

Availability comes third, which surprises people, because an outage is the most visible item on the list. It is third because outages end. You fix the query, add the index, put the courier call behind a cache, and the store comes back. Data that leaked does not come back.

The order is a default rather than a law. A store selling essential goods through a regional emergency has a genuine availability argument, and a plugin that only handles anonymised aggregate data has a weaker data argument than the shape of its tables suggests. Write the order down at the start of the session so that disagreements happen then, instead of when a finding needs a decision.

Findings you cannot fix this sprint

Do not close them, and do not leave them in a code comment. Write the finding, the asset it touches and the worst realistic outcome into the backlog as its own entry, name an owner, state the compensating control you are shipping in the meantime, and set a date to revisit it. The control is often cheap: a capability tightened, a rate limit added, the address removed from the response. A finding with an owner and a date is a decision to carry risk for a while. A finding in a comment is a decision nobody made.

Worked example: a tracking-lookup endpoint

The endpoint takes an order number or a link token and returns the shipment status along with the courier's tracking URL. The obvious implementation is a REST route with the order ID in the path, and it is the version that produces the most findings per line of code.

// The version the model is written against: no permission callback,
// the order ID comes from the URL, and the response includes the address.
register_rest_route( 'myplugin/v1', '/tracking/(?P<id>\d+)', array(
    'methods'  => 'GET',
    'callback' => 'myplugin_tracking',
) );

Walk that through the boundaries and the categories, and the table fills itself in.

CategoryWhat the attacker controlsWorst realistic outcomeControl
SpoofingThe order ID in the URLA sequential ID returns another customer's shipmentAn opaque token per order, with a lifetime
Information disclosureThe same ID, at any rate they likeOrder status and delivery address scraped in bulkNever return the address; rate limit per token
TamperingA posted email or postcode that selects the orderA lookup resolves against an order the caller does not ownResolve the order from the session or the token, not the body
Denial of serviceRequest volumeEvery request calls the courier API, which then rate limits the storeCache the status per order for a short window
Elevation of privilegeNothing, if the route is guardedAn unguarded route turns all of the above into anonymous accessA permission_callback that checks the order's owner
RepudiationThe ability to trigger lookupsNo record of which lookups hit the courier, so abuse stays invisibleLog the token, the order and the outcome

That table is the deliverable. Six rows, four of which became backlog items inside twenty minutes of a two-developer session, and one of which was already scheduled as a performance fix. That overlap is how a threat model gets funded: the availability row is the one the product owner already wanted.

The controls collapse into a guard on the handler.

add_action( 'rest_api_init', function () {
    register_rest_route( 'myplugin/v1', '/tracking/(?P<token>[a-f0-9]{32})', array(
        'methods'             => 'GET',
        'callback'            => 'myplugin_tracking',
        'permission_callback' => 'myplugin_can_track',
    ) );
} );

function myplugin_can_track( WP_REST_Request $request ) {
    // The token is the authorisation, so a miss is a denial, not an empty result.
    $order_id = myplugin_order_for_token( $request['token'] ); // Returns 0 on a miss.

    if ( ! $order_id ) {
        return new WP_Error( 'myplugin_unknown', 'Unknown tracking link.', array( 'status' => 404 ) );
    }

    // Staff with the order capability may follow any token.
    if ( ! current_user_can( 'manage_woocommerce' ) ) {
        $order = wc_get_order( $order_id );

        if ( ! $order || get_current_user_id() !== $order->get_customer_id() ) {
            return new WP_Error( 'myplugin_forbidden', 'Not allowed.', array( 'status' => 403 ) );
        }
    }

    return true;
}

Two details come straight out of the model. The 404 and the 403 are different answers on purpose, because a caller who guessed a token and a caller who owns an order are different problems for whoever reads the logs. And the token carries the authorisation, so the handler never decides what a token means: either the lookup succeeded or it did not.

Where the ninety minutes run out

The honest limit is that a ninety-minute model finds the classes of problem you already know about. STRIDE is a list of categories assembled from bugs somebody else found, and your team's instincts are a list of the bugs that already burned you. Those two lists find injection, missing capability checks, unsigned webhooks and unbounded loops, because those are the shapes they describe.

It does not find a logic flaw in your own business rules. A discount that stacks when two codes arrive in one request, a refund path that trusts whatever status the order is in, a free-shipping threshold compared against the wrong total, a courier webhook that accepts an order ID from the request body and marks that order shipped: none of those is a security category. Each is a statement about how the business works, written and read back by the same people. You cannot see the flaw because you agree with the rule.

That is why the last question in the model needs somebody from outside it. A second reader who did not write the code, and who reads the workflow instead of the handlers, will ask why a customer-supplied order ID is trusted at all. The answer will be "because that is how the integration works", which is the moment you have found your real problem. Book that reading as its own hour. It does not fit inside the ninety minutes.

Two other boundaries on the exercise. It does not cover what you did not draw: a dependency, an mu-plugin left by a previous agency, the deploy pipeline that writes files into the install. And it expires. Re-run it when the courier integration changes or a new role appears.

What to do on Monday

  1. Book ninety minutes and invite the people who write the handlers plus whoever owns the checkout workflow. The four questions go on the board.
  2. Draw the seven boundaries and fill in the columns for each. Stop when the columns are full; the point is not to be thorough.
  3. Walk STRIDE at the two boundaries that touch personal data and money, and skip it elsewhere.
  4. Turn every finding into an action with a name against it, and move anything touching customer data to the top.
  5. Write the findings you cannot fix this sprint into the backlog with an owner, a compensating control and a revisit date.
  6. Book a second hour with somebody who did not write the code, and ask them to read the business rules rather than the security list.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.

Continue

Related in Security & Hardening