A Guide to Writing Secure Themes – Part 4: Securing Post Meta

The previous two parts, we have gotten a high level overview of how to validate and sanitize data.

In the next few parts this series, we’ll look at how we can apply these concepts in specific contexts, starting with post meta.

Post meta database structure

When dealing with any kind of data, we need to first understand how it is stored in the database, and what the schema is.

Post meta is stored in the wp_postmeta table. It acts as a key/value storage for additional information about particular posts.

I encourage you to look at the structure of the table, because it provides us with a number of important information:

  • The post_id is the fastest way to look up data.
  • Queries against the meta_key are possible, but slower.
  • The meta keys need to be unique for the theme, which means we will need to use a prefix.
  • Meta values that are not scalars will be stored in serialized form.
  • Queries against meta values should be avoided. You cannot query against serialized data, and queries against LONGTEXT columns are super slow.

So post meta is meant for storing little additional bits of information related to individual posts. It shouldn’t be used to store custom CSS snippets, as a taxonomy replacement, or as a bucket for storing content from (rich) text editors.

So remember: just because the flexibility provided by WordPress allows you to do something, that doesn’t mean that it is a good idea to do so.

Now that we have seen how the underlying data storage works, let’s look at the user interface for entering post meta.

Post meta user interface

Post meta is entered via elements in the interface called meta boxes. You are probably familiar with the code for adding a meta box to the post editing interface:

<?php
function wptrt_add_meta_box() {
    add_meta_box( 'wptrt-sample-meta-box',  esc_html__( 'WPTRT Sample Meta Box', 'wptrt' ), 'wptrt_print_meta_box', 'post' );
}
add_action( 'add_meta_boxes', 'wptrt_add_meta_box' );
?>

When you look at the code of the add_meta_box() function, you’ll see that it stores the passed arguments in the global $wp_meta_boxes array.

So our custom meta box gets added to the same array in which the WordPress Core meta boxes are stored. This array is then used by the do_meta_boxes() function. It calls our custom callback function, wptrt_print_meta_box(), to print the meta box onto the screen.

To save the data entered via the meta box, you have to write a custom saving function, and hook it to save_post.

<?php
function wptrt_save_meta_box_data( $post_id ) {
    // Handle saving here
}
add_action( 'save_post', 'wptrt_save_meta_box_data' );
?>

The save_post action gets fired after the post has been saved. At this point WordPress has done all the work it needs to save the post data that Core handles.

The hook passes in three variables: the ID of the saved post, the post object (an instance of WP_Post), and a boolean, indicating whether it’s an update or a new post.

By looking at the underlying architecture, we can dedicate a number of things:

  1. In order to avoid conflicts with other meta boxes or meta data, we need to prefix everything.
  2. WordPress has no specific API for handling data submitted through custom meta boxes. We have to write our own data handling function.
  3. The save_post action is a generic action, that passes none of the data that was entered into the post edit form. This means that we’ll have to grab our data directly from the $_POST superglobal.
  4. No origin or capability checks have been performed for the HTTP request from which we are retrieving the data.

We’ll now see how we reflect these points in our code.

Dealing with autosaves

Auto saves are automatically triggered by WordPress at set intervals. These autosaves are done via an asynchronous request (AJAX request). For the duration of the processing of the request, the interface is “frozen”, to keep the user from entering more data.

The more work the database has to do, the longer this saving process is going to take. In order to reduce the time that users are stuck waiting on the autosave to finish, we’re going to put a check in place that avoids saving the post meta during this event.

<?php
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
    return;
}

The DOING_AUTOSAVE constant is defined by WordPress in the wp_autosave() function. When it is defined, and when its value evaluates to true, we return.

Protecting against unwanted requests

As we have seen before, we retrieve the data to save directly from a $_POST request. In the current stage of our code, we cannot distinguish between valid requests, made intentionally by a user via the admin interface, and a forged request by a malicious third party.

To protect against this, we use a nonce, a number used once. We add this information to the form, so that it gets send along with all the other data. We can then verify in the received $_POST data that the nonce is the same as the one we added to the form, and as such validate the request.

We will look at nonces in detail in a later part of this series. For now let’s add a nonce to the form with the wp_nonce_field() function.

<?php wp_nonce_field( 'wptrt-post-meta-box-save', 'wptrt-post-meta-box-nonce' ); ?>

The wp_nonce_field() function accepts four arguments, but we’re only using the first two: $action and $name.

$action refers to the context in which the data is generated. We’re going to use wptrt-post-meta-box-save here, to show that this data originates from the action of saving a meta box on the post screen.

The $name is the name of the hidden input field in the form that contains the nonce. We’re going to use this to retrieve the nonce later from the $_POST data.

As you can see, both the action and the name are prefixed, and unique to the situation. We’re going to see later why this is important, but please take note of this as a best practice.

Now let’s add a nonce check into our post meta saving function.

<?php
if ( ! isset( $_POST['wptrt-post-meta-box-nonce'] ) && ! wp_verify_nonce( $_POST['wptrt-post-meta-box-nonce'] ) ) {
    return;
}
?>

First we check whether the nonce data has been transmitted as part of the $_POST request data. Then we use wp_verify_nonce() to verify it.

If the nonce isn’t set, or if the wp_verify_nonce() function returns false, we return.

Verifying access rights

So far, we have completed two tasks: avoid saving data on autosaves, and ensuring that the data we work with was issued via the admin of the site.

But we haven’t verified whether the user that has entered the data has the right to modify post meta data. To check this, we’re going to use the built-in roles and capabilities provided by WordPress.

We’re going to look at this in more detail later, but for now, let’s try and find out what capability we would need to check.

Our post meta box is located on the Edit Post screen of the admin. So users should only be able to see our meta box when they can edit posts on the site.

That’s a good start, but we want to make sure that the user can edit the specific post for which we are saving the data. This is because there are many instances in which users have different access rights to different posts. Therefore we need to check for the rights on this particular post.

<?php
if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
}
?>

For checking the user’s capabilities, we use the current_user_can() function, and the edit_post capability. Since the capability refers to a single post, we pass in the $post_id variable, which we passed into our custom saving function, and which is provided by the save_post hook.

With this code in place, we can start the saving process. But before we get to that, let’s first look at how to work efficiently with post meta.

Working efficiently with post meta

There are a couple of things to keep in mind when working with post meta:

  1. Don’t save default values. Default values should be handled in the code, not via the database.
  2. Combine related data, separate individual data. When you repeatedly retrieve an array from post meta, only to use a single element, you would better use an individual key. On the other hand, when your code is littered with calls to get_post_meta() for little bits, you’d better consolidate those calls by using a single key.
  3. Separate related data by type. When storing related data, make sure not to mix different data types in the same key. Doing so would make things unnecessarily difficult to manage.

After lots of preparatory work, let’s get down to business: saving meta data.

Individual checkboxes

Let’s start off by looking at the simplest case: individual checkboxes. These are checkboxes used for handling features that are not related. Each checkbox is therefore saved with an individual key, to make the data easier to retrieve and use in the theme.

Here is the code for adding a checkbox. We will place this code inside our wptrt_print_meta_box() function.

<p>
    <input type="checkbox" id="wptrt-individual-checkbox" name="wptrt-individual-checkbox" value="1" <?php checked( get_post_meta( get_the_ID(), 'wptrt-individual-checkbox', true ) ); ?> />
    <label for="wptrt-individual-checkbox"><?php echo esc_html__( 'Individual Checkbox', 'wptrt' ); ?></label>
</p>

Note the use of the checked() function, which is part of a series of functions for working with inputs.

The code for saving the checkbox is straightforward:

<?php
if ( ! isset( $_POST['wptrt-individual-checkbox'] ) && get_post_meta( $post_id, 'wptrt-individual-checkbox', true ) ) {
        delete_post_meta( $post_id, 'wptrt-individual-checkbox' );
} else {
        update_post_meta( $post_id, 'wptrt-individual-checkbox', 1 );
}
?>

Here are the steps we take:

  • We verify first whether the wptrt-individual-checkbox key exists in the $_POST data. This is because checkboxes don’t submit any data if they are not checked.
  • If the checkbox is not checked, and we have a saved post meta value that evaluates to true, we delete the post meta data.
  • If the key exists, we save a 1 to the database with add_post_meta(). The 1 is hardcoded, so no data from the request will be saved.
  • The 1 is used because it gets evaluated to true due to PHP’s type comparison. We use an integer, because the data gets stored as a string, which is not a good fit for booleans.

If we want to know in our theme whether the user has checked the checkbox or not, we verify whether the retrieved data evaluates to true.

<?php
if ( get_post_meta( get_the_ID(), 'wptrt-individual-checkbox', true ) ) {
    // Checkbox was checked
} else {
    // Checkbox was not checked
}
?>

So the saved 1 evaluates to true. If no post meta was saved, get_post_meta() will return an empty string, which evaluates to false.

Individual text fields

Other individual fields, like for example a single text field, can be dealt with in a similar manner.

Let’s add a text input to our post meta box:

<p>
        <label for="wptrt-individual-text-field"><?php echo esc_html__( 'Individual Text Field', 'wptrt' ); ?></label>
        <input type="text" id="wptrt-individual-text-field" name="wptrt-individual-text-field" value="<?php echo esc_attr( get_post_meta( get_the_ID(), 'wptrt-individual-text-field', true ) ); ?>" />
</p>

This is how we would save this data:

<?php
if ( empty( $_POST['wptrt-individual-text-field'] ) ) {
        if ( get_post_meta( $post_id, 'wptrt-individual-text-field', true ) ) {
            delete_post_meta( $post_id, 'wptrt-individual-text-field' );
        }
} else {
        update_post_meta( $post_id, 'wptrt-individual-text-field', sanitize_text_field( $_POST['wptrt-individual-text-field'] ) );
}
?>

As you can see, the code is similar to the checkbox example, with a few key differences:

  • empty() is used instead of isset(). This is because text fields return an empty string when they don’t contain any data.
  • We separated the checks for the existence of submitted data and saved data into two different conditionals. This is because the text field might be empty and no data might be saved.
  • Instead of add_post_meta(), we use update_post_meta(). The update_post_meta() function either adds post meta or updates existing post meta depending on the case.
  • Since we are using data from the request, we are using sanitization to make sure that the data is secure before saving.

Checkbox groups

Checkbox groups are checkboxes that are related together. A common example for such a group would be options that allow to hide certain elements related to individual posts, such as the date, author, and the categories.

First, let’s print the markup for these three checkboxes:

<?php $hide_elements = (array) get_post_meta( get_the_ID(), 'wptrt-hide-post-element', true ); ?>
<p>
        <input type="checkbox" id="wptrt-hide-post-date" name="wptrt-hide-post-element[]" value="date" <?php checked( in_array( 'date', $hide_elements, true ) ); ?> />
        <label for="wptrt-hide-post-date"><?php echo esc_html__( 'Hide Date', 'wptrt' ); ?></label>
</p>
<p>
        <input type="checkbox" id="wptrt-hide-post-author" name="wptrt-hide-post-element[]" value="author" <?php checked( in_array( 'author', $hide_elements, true ) ); ?> />
        <label for="wptrt-hide-post-author"><?php echo esc_html__( 'Hide Author', 'wptrt' ); ?></label>
</p>
<p>
        <input type="checkbox" id="wptrt-hide-post-categories" name="wptrt-hide-post-element[]" value="categories" <?php checked( in_array( 'categories', $hide_elements, true ) ); ?> />
        <label for="wptrt-hide-post-categories"><?php echo esc_html__( 'Hide Categories', 'wptrt' ); ?></label>
</p>

We will save the data from these checkboxes under a single key, in form of an array.

As we have seen, get_post_meta() returns an empty string when no post meta data exists. We therefore use type casting to ensure that we are always dealing with an array.

As we are dealing with an array, we use in_array() to determine whether a checkbox needs to be checked or not. It will return true or false, which the checked() function then will use to print the correct markup.

Speaking of markup, all the input elements have the same name: wptrt-hide-post-element[]. This ensures that all the submitted data for these checkboxes will be provided as an array stored under the wptrt-hide-post-element key in the $_POST data.

Since the user only can select from the options we provide in the interface, we will use validation to secure the data.

<?php
if ( ! isset( $_POST['wptrt-hide-post-element'] ) ) {
        if ( get_post_meta( $post_id, 'wptrt-hide-post-element', true ) ) {
            delete_post_meta( $post_id, 'wptrt-hide-post-element' );
        }
} else {
        $safe_hide_post_element = array();

        foreach ( $_POST['wptrt-hide-post-element'] as $element ) {
            if ( in_array( $element, array( 'date', 'author', 'categories' ), true ) ) {
                $safe_hide_post_element[] = $element;
            }
        }

        if ( ! empty( $safe_hide_post_element ) ) {
            update_post_meta( $post_id, 'wptrt-hide-post-element', $safe_hide_post_element );
        }
}
?>

We will not talk about the first conditional in the code, since it is the same as we used in the previous example.

Instead let’s look at what happens when $_POST['wptrt-hide-post-element'] is set, meaning the user has checked at least one checkbox:

  • First we created a temporary array called $safe_hide_post_element. This is where we will save all valid data. The naming is very explicit, to clarify that this variable only holds secure data.
  • Next we loop over the array contained in $_POST['wptrt-hide-post-element'] and compare each entry against a list of possible values. Valid entries are then stored inside the $safe_hide_post_element array.
  • Finally we check whether the $safe_hide_post_element array contains any entries. This is to avoid saving an empty array in case the $_POST data did not contain any valid options. This array is then saved.

In the theme, you can then retrieve the data, and use in_array() to determine whether to display an element of the post or not:

<?php
$hide_elements = (array) get_post_meta( get_the_ID(), 'wptrt-hide-post-element', true );

if ( ! in_array( 'date', $hide_elements ) ) {
        echo '<p>' . esc_html__( 'Date:', 'wptrt' ) . esc_html( get_the_date() ) . '</p>';
}

if ( ! in_array( 'author', $hide_elements ) ) {
        echo '<p>' . esc_html__( 'Author:', 'wptrt' ) . esc_html( get_the_author() ) . '</p>';
}

if ( ! in_array( 'categories', $hide_elements ) ) {
        echo '<p>' . esc_html__( 'Categories:', 'wptrt' ) . '</p>';
        the_category();
}
?>

Do not repeat yourself checkbox groups

The code we have so far works well, but it is a bit repetitive in some places. If you have more complex requirements, a little bit more abstraction would be helpful to reduce repetitive code.

Let’s implement a feature that allows users to choose their favorite colors. We will need a key and a text with the color name for each option. Let’s implement a function that returns the options:

<?php
function wptrt_get_favorite_color_options() {
    return array(
        'blue'   => __( 'Blue', 'wptrt' ),
        'red'    => __( 'Red', 'wptrt' ),
        'yellow' => __( 'Yellow', 'wptrt' ),
    );
}
?>

We can then use this function inside a loop to print the checkboxes.

<?php
$favorite_colors = (array) get_post_meta( get_the_ID(), 'wptrt-favorite-color', true );
foreach ( wptrt_get_favorite_color_options() as $option => $text ) :
?>
        <p>
            <input type="checkbox" id="wptrt-favorite-color-<?php echo esc_attr( $option ); ?>" name="wptrt-favorite-color[]" value="<?php echo esc_attr( $option ); ?>" <?php checked( in_array( $option, $favorite_colors, true ) ); ?> />
            <label for="wptrt-favorite-color-<?php echo esc_attr( $option ); ?>"><?php echo esc_html( $text ); ?></label>
        </p>
<?php endforeach; ?>

Next, we need to modify our saving code:

<?php
if ( ! isset( $_POST['wptrt-favorite-color'] ) ) {
        if ( get_post_meta( $post_id, 'wptrt-favorite-color', true ) ) {
            delete_post_meta( $post_id, 'wptrt-favorite-color' );
        }
} else {
        $safe_favorite_color = array();

        foreach ( $_POST['wptrt-favorite-color'] as $color ) {
            if ( array_key_exists( $color, wptrt_get_favorite_color_options() ) ) {
                $safe_favorite_color[] = $color;
            }
        }

        if ( ! empty( $safe_favorite_color ) ) {
            update_post_meta( $post_id, 'wptrt-favorite-color', $safe_favorite_color );
        }
}
?>

With this setup, every time you add or remove an entry in the array returned by wptrt_get_favorite_color_options(), the display code and saving code will take this change into account.

Conclusion

By now, you should have a pretty good idea on how to safely store post meta. Feel free to copy the code samples and play around with them to gain more experience. You can find the entire code in this tutorial on Github.

In the next part, we’ll look at how to deal with custom widget settings.

#writing-secure-themes