The threat model
What an Attacker Actually Controls
A concrete inventory of what an attacker controls in a WordPress request: headers, cookies, upload names, timing, API responses and stored data.
A request arrives carrying a tracking number, a settings form, a CSV upload, and a cookie the plugin set last week. Each of those is a claim by whoever sent the request. The attacker is not constrained by the form you wrote, the field length you set in HTML, or the JavaScript validation that runs before submission.
The usual mental model of input stops at form fields. That model misses the headers, the parts
of an upload that are not its contents, the timing of requests relative to each other, and the
data the site reads back from somewhere else. Those channels are where findings hide, because a
scan that looks for $_POST will not find them.
What follows is the inventory of what an attacker controls at each handover from the previous lesson, in the order the data arrives.
WordPress has already touched the superglobals, and that is not sanitisation
Before your plugin loads, wp_magic_quotes() runs and applies
add_magic_quotes() to $_GET, $_POST,
$_COOKIE and $_SERVER. It also rebuilds $_REQUEST as a
merge of GET and POST, so a value in the body shadows one with the same name in the query
string. The slashes exist because the old database layer expected escaped strings. They are not
validation, and they do not make a value safe to use, store or print.
Pair wp_unslash() with the sanitising function for the destination: a value on
its way into an option gets sanitize_text_field( wp_unslash( $_POST['label'] ) ),
and a value on its way into an integer column gets absint( wp_unslash( $_POST['id'] )
). The unslash is required because the slashes were added on arrival, and skipping it
stores backslashes the user never typed.
Headers are input, and the ones that look like infrastructure are the worst
Three lines of PHP settle what a header is worth. Save this as probe.php and serve
it with the built-in server.
<?php
header( 'Content-Type: text/plain' );
echo 'HTTP_HOST: ', $_SERVER['HTTP_HOST'] ?? '(none)', "\n";
echo 'HTTP_USER_AGENT: ', $_SERVER['HTTP_USER_AGENT'] ?? '(none)', "\n";
echo 'HTTP_X_FORWARDED_FOR: ', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '(none)', "\n";
echo 'REMOTE_ADDR: ', $_SERVER['REMOTE_ADDR'] ?? '(none)', "\n";
php -S 127.0.0.1:8099 -t .
curl -s -H 'X-Forwarded-For: 203.0.113.9' -A 'curl-demo/1.0' \
-H 'Host: internal.example.test' http://127.0.0.1:8099/probe.php
That is the actual output on PHP 8.5.6 with the built-in server, and the shape of it holds on any SAPI:
HTTP_HOST: internal.example.test
HTTP_USER_AGENT: curl-demo/1.0
HTTP_X_FORWARDED_FOR: 203.0.113.9
REMOTE_ADDR: 127.0.0.1
Everything beginning with HTTP_ is a line the client wrote. REMOTE_ADDR
is the only one of the four the client did not choose, and it is still only the address of the
connection peer, which is a proxy or a CDN edge for most real traffic.
That matters when the value becomes a decision. A rate limiter keyed on
HTTP_X_FORWARDED_FOR is defeated by sending a different value on each request, and a
"trusted internal request" check that reads the same header lets the attacker claim to be
internal. The header is useful only when a proxy you control overwrites it in both directions
and you record that fact.
Cookies are client data with one verified exception
Every cookie in $_COOKIE arrived from the client, slashed by
wp_magic_quotes() and nothing else. Authentication is the exception: the logged-in
cookie is parsed and validated by wp_validate_auth_cookie(), and a forged value
fails the hash check.
Everything else a plugin stores in a cookie is a name and value the client can invent. A preference cookie that selects a template, a "return to" path, a cart identifier, a flag that skips a step in a checkout: all of them are request parameters that happen to travel in a header. If your code reads a cookie and acts on it without a check, the attacker writes that cookie.
The REST API adds a rule that catches people mid-debug. When a request authenticates with
cookies, core requires a nonce, from _wpnonce or the X-WP-Nonce header.
A cookie-authenticated request without one is treated as unauthenticated rather than rejected, so
the symptom is a permissions error from a route that works in the browser. The nonce is what
stops another site from riding the victim's cookies.
A WordPress nonce is not a single-use token
wp_verify_nonce() compares a hash against a value derived from the current time
tick, and stores nothing. The same nonce verifies as many times as the attacker can send it.
With the default nonce_life of one day it is good for two twelve-hour ticks.
Nonces prevent cross-site request forgery; they prevent neither replay nor hammering.
An upload hands over two channels, and the name is the quieter one
$_FILES gives you five values per file. name and type
are strings the client chose. tmp_name is a path PHP chose, and it is the only one
in the array that is entirely trustworthy.
name reaches your code before anything sanitises it, so a filename of
invoice.php or ..%2f..%2fwp-config.php is a value you have to handle.
The upload handler deals with the rest: wp_handle_upload() runs
wp_check_filetype_and_ext(), which maps the extension to a MIME type and, for image
types, reads the actual image MIME with wp_get_image_mime() before deciding the file
is what it claims. sanitize_file_name() normalises the name, and
wp_unique_filename() stops a second file from overwriting the first.
The second channel is the bytes. A polyglot file can be a valid image and valid PHP at the same
time, an SVG is XML that a browser will execute script inside, and a ZIP can contain paths that
climb out of the extraction directory. None of it matters until something serves the file from a
location where the server executes it, which is why the upload directory and the
php handler configuration matter as much as the upload code.
Timing and ordering are inputs that no filter validates
Values are not the only thing an attacker sends. They also choose how many requests to send, in what order, and how close together.
A check-then-act sequence has a window. Read a balance, decide it is sufficient, then write
the new balance, and two concurrent requests can both read the old value and both be approved.
WordPress offers no transaction around your handler. wp_cache_add() returns false
when the key already exists, which makes it a usable lock only when a persistent object cache is
installed. With the default cache the value lives in the process and dies with the request, so
the lock protects nothing.
Volume is the other half. Core rate limits nothing, so an endpoint that costs money per call or
checks a password is free to hammer unless you limit it. And because wp-cron.php
runs from a page load rather than a system scheduler, a cleanup job you scheduled for midnight
may run at 11am, which changes how long a stale record stays readable.
The first channel reviews miss: the response from a third-party API
A response from wp_remote_get() is input. It is also input that arrives inside
your process, from a hostname that DNS decided, over a body you did not write. The carrier
integration in the previous lesson is the shape of it: a status string that gets stored, a
customer name that gets rendered on an order screen, a URL that gets passed along.
The reason it gets skipped is that the code reads like a lookup rather than a handover. A
scanner sees wp_remote_get and moves on. Treat the decoded body the way you treat
$_POST: check that it is an object at all, check that the field you want exists and
is a string of the expected length, and escape it at the point of output. A field such as
$data['tracking_url'] that goes into a redirect needs a host allowlist, not
esc_url(), because escaping makes a hostile URL printable rather than safe.
Also check the status. wp_remote_get() returns a WP_Error on
transport failure and an array on success, with the HTTP status inside it. Code that assumes an
array and reads ['body'] directly turns a provider outage into a fatal error, and
code that stores a 500 response body stores an error page a human will read later.
The second channel reviews miss: lower-privilege writes read by higher privilege
The other handover nobody draws is the one with a delay in it. A customer writes a note on an order, a subscriber fills in a profile field, a contributor drafts a post. None of those users can reach the admin screens. All of their text does, because the order screen, the user list and the editor render it.
That is the whole reason escaping belongs at output rather than input. A value that was acceptable in one context is not acceptable in the next. A note that was stripped of tags on save is plain text in the database, and it is still a string that has to be escaped for HTML when an administrator's browser draws it. Sanitising on input and trusting the stored value later is how a customer's order note becomes script in an admin session.
The same reasoning applies to anything that outlives the request. Options, post meta, user meta, transients, log files and exported CSVs are all storage, and storage is read by somebody with more privilege than the person who wrote it. When you classify a datum, the useful question is not only "who can send this" but "who renders this later, and in what context".
Take the tracking plugin from the previous lesson and write down every datum that reaches it during one lookup and one settings save. Include the ones you do not normally call input: the request method, the headers, the cookies, the query string, the body, the uploaded file if there is one, and the JSON that comes back from the carrier.
For each datum, record four things: where it enters, whether an anonymous visitor controls it, whether the value is checked anywhere, and the highest-privilege user who eventually reads or acts on it.
Then answer two questions. Which datum does the plugin treat as infrastructure rather than input? And which datum is written by the least privileged user on the site and rendered to the most privileged one?
Show the solution and the reasoning
A lookup through a REST route produces something like this. The entries that matter are not the obvious ones.
Datum Enters at Anon? Checked Highest reader
HTTP_HOST header yes no link building
HTTP_X_FORWARDED_FOR header yes no rate limiter
Cookie acme_tracking_id cookie yes no lookup handler
number (query, unvalidated) query string yes no carrier API call
number (query, validated) query string yes regex + length order screen
api_key (settings POST) body no manage_options options
carrier status JSON API response no no order screen
customer note (database) earlier write yes on save only admin order screen
$wpdb->prepare() arguments plugin code no prepare none
The datum treated as infrastructure is the forwarded IP header, and it is the one people defend. A rate limiter keyed on it can be reset per request by changing one line of a curl command, so the limiter is decorative. The fix is to have a proxy you operate overwrite it, and to let only that proxy set it.
The datum written by the least privileged user and rendered to the most privileged one is
the customer note. On the write path it may have been passed through
sanitize_text_field(), which is correct and still irrelevant at the order screen,
because the order screen builds HTML and the note is data being placed inside it. Every
rendering site needs its own escaping, and a stored value is a new rendering site each time it
is read.
The cookie is worth a second look because it looks harmless. A tracking id in a cookie that the lookup handler passes to the carrier as a customer identifier means the attacker chooses which customer's shipment the response describes. The fix is to resolve identity from the session and then check that this user may see this object.
The carrier response is the row people forget to include at all, which is why it ends up in the table unescaped. Nothing about a paid integration makes its response trustworthy. It arrives over the same network as everything else, and it is displayed to the user with the most privileges in the system.
Progress
Mark this lesson done and it stays marked in this browser.