Escaping and securing Advanced Custom Fields output

Do you use get_field() to output values saved in Advanced Custom Field in your theme or plugin without escaping it first? Maybe something like this?

echo get_field('my_field');

If so, your site is likely vulnerable to cross-site scripting attacks (XSS) and other malicious hijacking by users who have access to your ACF forms! (Through the admin backend or frontend, if using acf_form())

ACF author Elliot Condon is not completely sure whether ACF escapes input when saving posts to database. However, it doesn’t.

Who is affected by this?

The issue arises from potentially harmful data collected on the front or backend using ACF forms, that is then displayed without proper escaping.

If you do not use frontend forms via acf_form()
In this case you are not vulnerable to unauthenticated frontend attacks. However, anyone who has access to /wp-admin and can edit ACF forms that are output on the front or backend, can introduce XSS vulnerabilities unless you escape the output.

Aside from Administrator accounts getting hacked (in which case you are probably not in a great position anyway), you may also have less privileged users that still have access to ACF forms. (For example, using Author or Editor roles.). For these users, /wp-admin is not an “admin” panel in the traditional sense, and again, it would take for only one of these users to have his credentials stolen or brute forced to bring down your site.

Once the attacker logs in, he can input javascript code into any text-based fields, doing things like redirecting users to malicious sites, steal passwords, or collect data about your site and users.

If you use frontend forms and let unauthenticated users fill them out
In this case, you need to make sure that you escape the output everywhere, be it front or backend.

Now that we’ve cleared this out – onwards to the solution!

ACF 4 vs ACF 5 / ACF PRO

In ACF 4, you can escape text and textfield fields by using get_field() and setting “Formatting: No Formatting” when configuring the fields. In the upcoming ACF 5 (and ACF PRO), this has been removed and replace with a format_value filter. Unfortunately, this filter does not let you target an individual field. In that case you have to escape data on the frontend.

Securing Advanced Custom Fields

Luckily, it’s easy to do escaping on the front end with built-in WordPress escaping functions.

I’ve written a handly helper function called get_field_escaped() which you can use instead of get_field() to secure your output.

It takes the same parameters as get_field(), so you can do stuff like:

echo get_field_escaped('my_custom_field');

You can also pass $post_id and $format_value, just like in the regular get_field() function:

echo get_field_escaped('my_custom_field', $post_id, true);

What’s new, a new fourth parameter has been added. It selects which escaping method is used on the data before it is sent back. By default is uses esc_html, which is an all-round function for escaping HTML.

You can easily switch this for any other escaping function that WordPress has, for example here we escape a URL:

<a href="<?php echo get_field_escaped('url', $post_id, true, 'esc_url'); ?>">My link</a>

And here we are escape a HTML attribute:

<div class="<?php get_field_escaped('custom_css_class', $post_id, true, 'esc_attr'); ?>">My div</div>

You can also pass NULL as the fourth parameter instead of the function name to skip the escaping. In that case it works exactly as get_field():

echo get_field_escaped('my_field', $post_id, true, NULL);

And the function itself:

/**
 * Helper function to get escaped field from ACF
 * and also normalize values.
 *
 * @param $field_key
 * @param bool $post_id
 * @param bool $format_value
 * @param string $escape_method esc_html / esc_attr or NULL for none
 * @return array|bool|string
 */
function get_field_escaped($field_key, $post_id = false, $format_value = true, $escape_method = 'esc_html')
{
    $field = get_field($field_key, $post_id, $format_value);

    /* Check for null and falsy values and always return space */
    if($field === NULL || $field === FALSE)
        $field = '';

    /* Handle arrays */
    if(is_array($field))
    {
        $field_escaped = array();
        foreach($field as $key => $value)
        {
            $field_escaped[$key] = ($escape_method === NULL) ? $value : $escape_method($value);
        }
        return $field_escaped;
    }
    else
        return ($escape_method === NULL) ? $field : $escape_method($field);
}

While we’re at it, let’s add the_field_escaped() as well in case we want to print the value directly.

/**
 * Wrapper function for get_field_escaped() that echoes the value isntead of returning it.
 *
 * @param $field_key
 * @param bool $post_id
 * @param bool $format_value
 * @param string $escape_method esc_html / esc_attr or NULL for none
 */
function the_field_escaped($field_key, $post_id = false, $format_value = true, $escape_method = 'esc_html')
{
    //Get field
    $value = get_field_escaped($field_key, $post_id, $format_value, $escape_method );

    //Print arrays as comma-separated strings, as per get_field() behaviour.
    if( is_array($value) )
    {
        $value = @implode( ', ', $value );
    }

    //Echo result
    echo $value;
}

That lets us do:

//Prints the value
the_field_escaped('my_field', $post_id, true, NULL);

XSS self-test

If you want to perform an XSS self-test, you can enter the following code in all your ACF text fields:

<script>alert('pwned')</script>

Then, visit the frontend on your site. If you see something similar to this message, you are susceptible to XSS:

xss

Other resources

Validating, Sanitizing, and Escaping, from WordPress VIP

15 thoughts on “Escaping and securing Advanced Custom Fields output

  1. Monika T-S

    Hi Stanislaw
    I found your blog while searching for the author of “Email Obfuscate Shortcode” => .. my curiosity was piqued :-)

    Your function makes the task a lot easier.

    Thanks a lot!
    Monika

    Reply
  2. Marco

    Hey Stanislav

    Does this also apply to the_field and the_sub_field in cases using repeater fields? Or can we leave these as they are?

    Many thanks!

    Reply
    1. Stanislav Khromov Post author

      Hey Marco,

      the_field() is just a wrapper for get_field(), which we can see in the ACF source code:
      https://github.com/elliotcondon/acf/blob/686eba9290c7133f492fbc3bc318e4c50804a2a5/core/api.php#L331

      the_sub_field() is a wrapper for get_sub_field(), which is a bit more complex.

      Generally, I would suggest always using get_sub_field(), and add appropriate escaping, for example:

      echo esc_html(get_sub_field($sub_field_name));
      

      You can also write a wrapper similar to get_field_escaped().

      Hope that helps!

      Reply
      1. David Hedley

        Hi Stanislav,

        Do you happen to have an example wrapper for the_field_escaped() by any chance?

        Thanks in advance!

        Reply
        1. Stanislav Khromov Post author

          Hey David,

          I added the code for the_field_escaped() at the end of the post. Let me know if you have any troubles with it. Cheers!

          Reply
      1. Stanislav Khromov Post author

        Haha, that’s quite an embarrassing mistake. Thanks for the correction Nick! :-)

        Reply
  3. Jenny Beaumont

    Fantastic post, thank you! Have a question – I often use a simple text field for URLs because I want a way to input a “nice” address for display, to which I’ll add the HTTP:// to the link. In this case, what’s the most secure way to escape this whole link? With part of it being manually coded, the other part retrieved from a custom field?
    Many thanks in advance !
    -jennyb

    Reply
    1. Stanislav Khromov Post author

      Hey Jenny,

      You can escape URL:s with the esc_url() function after you have finished gluing it together, ie:


      echo esc_url('http://'. get_field('your_url_field'));

      Or, if you want to use the helper function I wrote for this post, you can use the following code:


      echo 'http://' . get_field_escaped('your_url_field', false, true, 'esc_url');

      You probably also want to check if the user added http:// or https:// himself in the field and add/remove your “http://” string accordingly so you don’t get it twice by mistake. Many browsers now include the http(s):// part when you copy and paste a URL from the address bar.

      Reply
    1. Stanislav Khromov Post author

      That code example gives basic support for removing dangerous HTML from all fields as they are saved.

      There are multiple issues with this approach.

      For one, it mangles your data as it makes its way into the database. Sometimes that means the user inputs value A but gets something else (B) in return. The preferable way is to escape data as it is being output instead.

      Second, wp_kses() is not suitable as a “catch-all” solution. Some fields are output in places like HTML attributes or inline JavaScript. This is why WordPress has a bunch of context-specific escaping functions like esc_html, esc_js, esc_attr and so on. So why not use the “right” functions for the job? :-)

      Interestingly, the format_value filter has been updated in a recent version of ACF PRO to let you target it by field, for example:
      acf/format_value/key=KEY, so now you can at least use that filtering approach instead of escaping in the templates.

      Reply
  4. Pingback: From Avada to Underscores - Web Jigsaw

Leave a Reply

Your email address will not be published. Required fields are marked *

Markdown is allowed in comments.