Security and quality
Sanitising Input and Escaping Output in a Plugin
Where input enters a WordPress plugin, where output leaves it, and the single step WordPress leaves to you: removing the slashes it adds to the request.
Two people report the same bug on the same afternoon. The first types a company name with an
apostrophe into your settings form and it saves as O\'Brien. The second has a note in your
plugin called O'Brien, types the same name into a search box, and gets no results, even
though the note is sitting right there in the database. Both bugs start in the same place: the code
sitting between a request and your own data.
WordPress does almost nothing to protect that gap. It does not validate the request for you, it does not sanitise it, and it adds one thing you did not ask for. That one thing is slashing, and it is the step plugin authors skip most often because nothing in the request looks wrong. The rest of this lesson is about which side of a request sanitising belongs on, and which side escaping belongs on.
Input reaches a plugin through more doors than $_POST
Superglobals are the obvious entry point and the least structured. A form handler reads
$_POST, an export link reads $_GET, a REST route reads its own parameter
array. Each of those needs a sanitise call before the value touches the database, an option, or a
comparison.
The less obvious entries are the ones WordPress hands you already parsed, which can give you the
false impression that parsing equals sanitising. A shortcode callback is called with an attribute
array built by shortcode_parse_atts(). A dynamic block's render_callback is
called with an attributes array that came from the saved post content, or from a REST request to the
block renderer. A settings page hands your sanitize_callback whatever was posted, and
the Settings API calls it for you. An admin_post_{action} handler receives nothing at all;
you read the request yourself.
The rule that makes all of these tractable is to name the boundary and put sanitising exactly
there. The shortcode callback is a boundary: treat its $atts as hostile even though the
parser stripped whitespace for you. The REST sanitize_callback is a boundary. The
admin_post_ handler is a boundary. Inside those functions a value is sanitised once; past
them it is your data, and you do not sanitise it again on the way to the database.
WordPress slashes the request before your code sees it
Since 3.0, wp_magic_quotes() in wp-includes/load.php runs during startup
and applies add_magic_quotes() to $_GET, $_POST,
$_COOKIE and $_SERVER, then rebuilds $_REQUEST as
$_GET + $_POST. Every quote, double quote and backslash in the request arrives escaped.
This is not the PHP magic_quotes_gpc setting, which was removed in PHP 5.4; it is
WordPress doing it deliberately so that a value can be passed to a query without further work.
That convenience is a trap for plugin code, because sanitize_text_field() does not
remove the backslash. It strips tags, removes line breaks and extra whitespace, and returns the
escaped string. Store that and you have stored O\'Brien. Compare it against your stored
title and it will never match.
// What the plugin usually writes first.
$title = sanitize_text_field( $_POST['wpsifero_title'] ?? '' );
// What it has to write.
$title = sanitize_text_field( wp_unslash( $_POST['wpsifero_title'] ?? '' ) );
wp_unslash() is a wrapper around stripslashes_deep() and works on arrays
as well as strings, so a whole $_POST array can be unslashed in one call when a handler
needs many fields. Order matters: unslash first, sanitise second. Sanitising first and unslashing
afterwards is worse than useless, because sanitising has already changed the string.
Some boundaries do not need it, and knowing which ones saves noise. WordPress's REST server passes
wp_unslash( $_GET ) and wp_unslash( $_POST ) into the request object in
class-wp-rest-server.php before any sanitize_callback runs, so a REST
parameter arrives unslashed and calling wp_unslash() inside the callback is a no-op.
check_admin_referer() reads $_REQUEST directly, which is why the nonce
sometimes looks slashed and usually is not: nonces are hashes truncated to ten hex characters and
contain no quotes. Any value you read from a superglobal yourself is slashed. The two places where that
matters most in a plugin are loops over posted arrays and anything compared against stored data.
esc_html on stored data breaks the comparison that follows
This is the mistake that produces the search bug from the opening paragraph. Here is the whole of it, with the output from the plugin's own test suite underneath.
function wpsifero_notes_find_by_title( string $needle ): string {
$notes = get_option( 'wpsifero_notes', array() );
foreach ( $notes as $slug => $note ) {
if ( $note['title'] === $needle ) {
return (string) $slug;
}
}
return '';
}
$posted = addslashes( "O'Brien" ); // what the request carries
wpsifero_notes_find_by_title( sanitize_text_field( $posted ) ) => ''
wpsifero_notes_find_by_title( sanitize_text_field( wp_unslash( $posted ) ) ) => 'obrien'
The escaped version compares O\'Brien against the stored O'Brien and
returns nothing. There is no error, no log line and no exception: an empty result is a valid result,
which is why this class of bug survives so long.
Escaping has the same property. esc_html() turns & into &
and " into ", so a title escaped on the way into an option is a
different string from the title the user typed, and every comparison, deduplication and
sanitize_title() key computed from it is wrong in a way that only shows up on the rows
containing those characters. The fix is not a better escape function. The fix is that escaping is an
output operation and has no business on the write path.
The short version of the whole boundary: sanitize_* on the way in, esc_*
at the moment of printing. A value that has been sanitised once and not escaped until the last
possible moment is safe, comparable, and searchable. A value escaped on the way in is none of those
things.
Each output context has exactly one escape function
Printing is not one operation. The function that makes a string safe inside a <div>
is wrong inside an attribute, wrong inside a URL and dangerous inside a <script>
block. The rule is to escape as late as possible and for the exact context the value will sit in.
| Context | Function | Output for the string Tom & "Jerry" |
|---|---|---|
| Text between tags | esc_html() | Tom & "Jerry" |
| An HTML attribute value | esc_attr() | the same characters, with the quotes the attribute needs |
An href or src | esc_url() | tags in the query string are stripped, not encoded |
| A URL being stored | esc_url_raw() | for the database rather than for display |
A <textarea> | esc_textarea() | HTML-escapes and keeps newlines intact |
| A JavaScript string literal | esc_js() | quotes the string for a single-quoted JavaScript context |
| A JSON payload in a script tag | wp_json_encode() | with JSON_HEX_TAG it emits \u003C for every < |
The URL row is worth testing rather than remembering. esc_url() does not percent-encode
a tag in a query string; it removes the angle brackets, so
https://example.org/?a=1&b=<script>alert(1)</script> renders as
https://example.org/?a=1&b=scriptalert(1)/script. That is safe and also not the
URL the user typed, which is why a link the user pastes with a + or a bracket can come
back changed. The alternative at a plugin boundary is esc_url_raw() on the way into the
database and esc_url() on the way out, so the stored value is the one the user gave
you.
For data going to a script, wp_localize_script() is not an escaping function and has
one behaviour that ruins strings. It runs each scalar through
html_entity_decode( (string) $value, ENT_QUOTES, 'UTF-8' ), so a value containing
& or & arrives in JavaScript with those sequences decoded,
and an array is encoded as JSON and left alone. Since 5.7, passing anything other than an array
triggers a _doing_it_wrong() notice pointing at wp_add_inline_script(). The
short answer for a plugin that needs a nonce and a few settings in the browser is
wp_add_inline_script() with wp_json_encode( $data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
which is what core uses and what keeps a stored </script> from closing your tag.
wp_kses is for the subset of HTML you decided to allow
Sometimes escaping is the wrong answer because you want the user's markup to survive. A note body
that may contain a link and bold text is a normal plugin requirement, and
esc_html() flattens it. That is what wp_kses() is for: you pass the allowed
tags and attributes, and everything else is removed.
$allowed = array(
'a' => array(
'href' => true,
'title' => true,
),
'strong' => array(),
'em' => array(),
);
$filtered = wp_kses( '<p>Read <a href="https://example.org/" onclick="steal()">this</a> now.</p>', $allowed );
// Read <a href="https://example.org/">this</a> now.
The onclick attribute disappears because it is not in the list, and the p
is stripped along with any tag not named. wp_kses() also checks the protocol of every URL
attribute against wp_allowed_protocols(), which is why javascript: never
survives even when href is allowed. Passing the string 'post' instead of an
array uses the same allowed set as a post body, and it is the right shortcut only if you actually want
that set.
Two limits are worth stating plainly. wp_kses() is comparatively expensive, so do not
run it on every request over a large body; filter once when the value is saved and treat the stored
value as the sanitised one. And it is not a substitute for escaping the variables you print beside the
filtered content. A hybrid is common and correct: wp_kses() for the body the user wrote,
esc_html() for the title next to it.
The handler below was written quickly and has four separate problems. Find all four, fix them, and say which of them would have been visible to the person who tested it and which would not have been.
function wpsifero_notes_handle_save(): void {
$notes = get_option( 'wpsifero_notes', array() );
foreach ( $_POST['wpsifero_notes'] as $note ) {
$slug = sanitize_title( $note['title'] );
$notes[ $slug ] = array(
'title' => esc_html( $note['title'] ),
'url' => $note['url'],
);
}
update_option( 'wpsifero_notes', $notes );
echo '<p>Saved ' . count( $notes ) . ' notes.</p>';
}
Then answer: for a site whose titles never contain an apostrophe, ampersand or angle bracket, how many of the four problems would the test above have caught?
Show the solution and the reasoning
The four problems, in the order they bite.
The posted array is not unslashed. $_POST['wpsifero_notes'] has
been through add_magic_quotes(), so every element is slashed. Feed the slashed title
into sanitize_title() and a title like O'Brien & Sons produces a slug
built from O\'Brien & Sons. The slug is wrong, the note is stored under the wrong
key, and a later lookup by the title the user typed finds nothing. The fix is
wp_unslash( $_POST['wpsifero_notes'] ) once, before the loop, because
wp_unslash() recurses.
The title is escaped on the way into the database.
esc_html() belongs at the point of printing, and here it is doing the opposite:
storing an HTML-escaped title. Every later comparison, deduplication or export of that title
operates on the escaped string. It is the exact failure the opening paragraph describes, committed
in the other direction. Use sanitize_text_field() here and escape at output.
The URL is not sanitised at all. A URL from a form is the single most dangerous
value in a plugin when it eventually reaches an href, because
javascript:alert(document.cookie) is a perfectly valid URL string. Sanitise it at the
boundary with esc_url_raw(), and escape it again with esc_url() when it is
printed. If you would rather not store it at all, validate the scheme against an allowlist you
control.
The count is echoed without escaping. count() returns an integer,
so this particular echo is safe today. It is still a violation of the rule, and it is the one that
gets copied into the next function that echoes a string. Wrap it:
echo '<p>' . esc_html( sprintf( ... ) ) . '</p>'; or, better, return the
markup instead of echoing it.
function wpsifero_notes_handle_save(): void {
$posted = wp_unslash( $_POST['wpsifero_notes'] ?? array() );
$notes = get_option( 'wpsifero_notes', array() );
foreach ( (array) $posted as $note ) {
$title = sanitize_text_field( $note['title'] ?? '' );
if ( '' === $title ) {
continue;
}
$notes[ sanitize_title( $title ) ] = array(
'title' => $title,
'url' => esc_url_raw( $note['url'] ?? '' ),
);
}
update_option( 'wpsifero_notes', $notes );
wp_safe_redirect( admin_url( 'tools.php?page=wpsifero-notes' ) );
exit;
}
The question at the end is the one worth taking away. On a site whose data contains no
apostrophes, ampersands or angle brackets, all four bugs are invisible. The slashed slug is
identical to the clean one, the escaped title is byte-identical to the sanitised title,
esc_url_raw() and esc_url() are no-ops on a URL with a clean query string,
and an integer echo is an integer. The test data decided whether the bugs existed. That is why
the fix for this class of mistake is a test fixture with an apostrophe and an ampersand in it, not a
code review.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.