Safely escape Twig's json_encode() without using raw

Author: Leighton
Tuesday, May 10 2016

Twig will sometimes escape output, which can be a real problem if you're relying on it to build your web page or provide data to something like a piece of JavaScript. Twig's raw provides an easy solution to this, but opens up a realm of potential security issues leaving you more vulnerable than you think. Here's another solution to help you stay secure and get the job done.

The problem

In one of our projects, we were passing an array information into our Twig file to be run through json_encode and ran into a bit of a problem. Because of Twig's escaping, the JSON was malformed, with quotes around strings being replaced with ". Even moving the encoding over to PHP and passing it directly into Twig didn't solve our problem.

In an attempt to fix our issue after trying a few other methods, we saw that most people experiencing similar problems were being told to use raw or

{% autoescape false %}
...
{% autoescape %}

While these are working solutions, they cause more problems than they solve. By turning off autoescape or using raw, you're making your site potentially susceptible to XSS. Even if you know where the 'JSON' is coming from, it's always better to be safe and make sure it's sanitized.

Our solution

Luckily, Twig provides a cool way of adding additional functionality to your templates through the use of extensions. This is what we utilised to make sure our JSON was correct and safe. You can visit http://twig.sensiolabs.org/doc/advanced.html#creating-an-extension for official information on Twig extensions.

To start off we created a new empty extension class for Twig:

<?php

namespace AppBundle\Twig;

use Twig_Extension;

class OurNewExtension extends Twig_Extension
{
    public function getFilters()
    {
    }

    public function applyOurFilter($input)
    {
    }

    public function getName()
    {
        return 'our_new_extension';
    }
}

Next, we added the following to the getFilters() method:

return [
    new \Twig_SimpleFilter(
        'json_encode_filtered', 
        [$this, 'applyOurFilter'], 
        ['is_safe' => ['html']]
    ),
];

By setting is_safe to ['html'] in the options, we're telling Twig that it should output whatever is returned as safe HTML. It's okay to do this because our filter will be acting as an escaper, meaning that any malicious code will already have been stripped away leaving only the things we want. It's important to be aware that json_encode_filtered in this case is the name we will use in Twig templates when we call our new extension. [$this, 'applyOurFilter'] is where to find the method it should call, along with the name of said method.

After that we added our sanitation and JSON encoding to the applyOurFilter method:

    public function applyOurFilter($input)
    {
        $array = $this->recursiveSanitizeArray($input);

       return json_encode($array);
    }

    public static function sanitize($input)
    {
        $sanitized = $input;

        if ( ! is_int($sanitized)) {
            $sanitized = filter_var($sanitized, FILTER_SANITIZE_SPECIAL_CHARS);
        } else {
            $newValue = filter_var($sanitized, FILTER_SANITIZE_SPECIAL_CHARS);

            if (is_numeric($newValue))
            {
                $sanitized = intval($newValue);
            } else {
                $sanitized = $newValue;
            }
        }

        return $sanitized;
    }

    public function recursiveSanitizeArray($array)
    {
        $finalArray = [];

        foreach ($array as $key => $value)
        {
            $newKey = self::sanitize($key);

            if (is_array($value))
            {
                $finalArray[$newKey] = $this->recursiveSanitizeArray($value);
            }
            else
            {
                $finalArray[$newKey] = self::sanitize($value);
            }
        }

        return $finalArray;
    }

This looks a bit more complicated than it actually is, but it'll just go through a given array ($input) and change all keys/values to a sanitized version of themselves, and will keep numbers the same if at all possible. This is needed because filter_var() will turn integers into a string, and we're using it to strip any special characters. You could also use something like strip_tags() if you want some special characters to be allowed through, it's all up to you - that's the beauty of Twig's extensions.

The next thing to do is add your extension as a service as per the Twig documentation,

appbundle.our_new_extension:
      class: AppBundle\Twig\OurNewExtension
      public: false
      tags:
       - { name: twig.extension }

use your new extension in your template,

{{ data|json_encode_filtered() }}

and move onto the most important step: writing some tests. That one's all up to you.

Running Behat in PHPStorm EAP through Vagrant 3 useful tips for writing Alice fixtures