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:
Other resources
Validating, Sanitizing, and Escaping, from WordPress VIP