Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 25 MIN

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.

ContextFunctionOutput for the string Tom & "Jerry"
Text between tagsesc_html()Tom &amp; &quot;Jerry&quot;
An HTML attribute valueesc_attr()the same characters, with the quotes the attribute needs
An href or srcesc_url()tags in the query string are stripped, not encoded
A URL being storedesc_url_raw()for the database rather than for display
A <textarea>esc_textarea()HTML-escapes and keeps newlines intact
A JavaScript string literalesc_js()quotes the string for a single-quoted JavaScript context
A JSON payload in a script tagwp_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&#038;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 &#038; or &amp; 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.

Lab 30 minutes

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.