SafeResponse component

I’ve prepared extended Response component for additional security headers. Let me know what you think about this.

SafeResponse component at GitHub

Additional headers:

  • Content-Security-Policy

  • X-Frame-Options

  • X-XSS-Protection

  • Strict-Transport-Security

  • X-Content-Type-Options

  • Public-Key-Pins

Remember to configure the component options to suit your needs because the default ones might be too restrict.

To use the component add it to your application, change the namespace to fit the file location and replace the default Response component with this one in configuration:




'components' => [

    'response' => [

        'class' => 'your\namespace\here\SafeResponse',

        // configuration here

    ],

],



For HTTPS Cookies remember to set safe Cookies in configuration as well i.e.:




'components' => [

    'user' => [

        // configuration here

        'identityCookie' => [

            'name' => '_identity',

            'httpOnly' => true,

            'secure' => true,

        ],

    ],

    'request' => [

        // configuration here

        'csrfCookie' => [

            'httpOnly' => true,

            'secure' => true,

        ],

    ],

    'session' => [

        // configuration here

        'cookieParams' => [

            'httpOnly' => true,

            'secure' => true,

        ],

    ],

],



This is the basic idea to get strict CSP rules working with Yii 2 registerJs() method.

Default SafeResponse CSP rules are:




public $cspDirectives = [

    'default-src' => "'none'",

    'connect-src' => "'self'",

    'img-src' => "'self'",

    'script-src' => "'self'",

    'style-src' => "'self'"

];



With these only same domain files are allowed and no inline CSS or JS can be used - this means everything added with registerJs() is skipped.

To avoid that I’ve created extended View component that merges all code blocks added with registerJs() and sends it to separate controller so it can be rendered as standalone JS file.




<?php


namespace /* change it according to your app structure */;


use ArrayAccess;

use yii\di\Instance;

use yii\helpers\Html;

use yii\web\View as YiiView;


/**

 * View component rendering inline JS in separate file.

 */

class View extends YiiView

{

    const STORAGE_JS_HEAD = 'JSBlockHead';

    const STORAGE_JS_BEGIN = 'JSBlockBegin';

    const STORAGE_JS_END = 'JSBlockEnd';

    const STORAGE_JS_READY = 'JSBlockReady';

    const STORAGE_JS_LOAD = 'JSBlockLoad';

    const STORAGE_JS_AJAX = 'JSBlockAjax';

    

    /**

     * @var string|array|ArrayAccess storage component

     */

    public $storage = 'session';

    

    /**

     * Ensures storage component is set.

     */

    public function init()

    {

        parent::init();

        $this->storage = Instance::ensure($this->storage, '\ArrayAccess');

    }

    

    /**

     * Renders the content to be inserted in the head section.

     * The content is rendered using the registered meta tags, link tags, CSS/JS code blocks and files.

     * @return string the rendered content

     */

    protected function renderHeadHtml()

    {

        $lines = [];

        if (!empty($this->metaTags)) {

            $lines[] = implode("\n", $this->metaTags);

        }


        if (!empty($this->linkTags)) {

            $lines[] = implode("\n", $this->linkTags);

        }

        if (!empty($this->cssFiles)) {

            $lines[] = implode("\n", $this->cssFiles);

        }

        if (!empty($this->css)) {

            $lines[] = implode("\n", $this->css);

        }

        if (!empty($this->jsFiles[self::POS_HEAD])) {

            $lines[] = implode("\n", $this->jsFiles[self::POS_HEAD]);

        }

        if (!empty($this->js[self::POS_HEAD])) {

            $this->storage->offsetSet(self::STORAGE_JS_HEAD, $this->js[self::POS_HEAD]);

            $lines[] = Html::jsFile(['js/head', 'hash' => $this->hashKeys($this->js[self::POS_HEAD])]);

        }


        return empty($lines) ? '' : implode("\n", $lines);

    }

    

    /**

     * Renders the content to be inserted at the beginning of the body section.

     * The content is rendered using the registered JS code blocks and files.

     * @return string the rendered content

     */

    protected function renderBodyBeginHtml()

    {

        $lines = [];

        if (!empty($this->jsFiles[self::POS_BEGIN])) {

            $lines[] = implode("\n", $this->jsFiles[self::POS_BEGIN]);

        }

        if (!empty($this->js[self::POS_BEGIN])) {

            $this->storage->offsetSet(self::STORAGE_JS_BEGIN, $this->js[self::POS_BEGIN]);

            $lines[] = Html::jsFile(['js/begin', 'hash' => $this->hashKeys($this->js[self::POS_BEGIN])]);

        }


        return empty($lines) ? '' : implode("\n", $lines);

    }

    

    /**

     * Renders the content to be inserted at the end of the body section.

     * The content is rendered using the registered JS code blocks and files.

     * @param boolean $ajaxMode whether the view is rendering in AJAX mode.

     * If true, the JS scripts registered at [[POS_READY]] and [[POS_LOAD]] positions

     * will be rendered at the end of the view like normal scripts.

     * @return string the rendered content

     */

    protected function renderBodyEndHtml($ajaxMode)

    {

        $lines = [];


        if (!empty($this->jsFiles[self::POS_END])) {

            $lines[] = implode("\n", $this->jsFiles[self::POS_END]);

        }


        if ($ajaxMode) {

            $scripts = [];

            if (!empty($this->js[self::POS_END])) {

                $scripts = array_merge($scripts, $this->js[self::POS_END]);

            }

            if (!empty($this->js[self::POS_READY])) {

                $scripts = array_merge($scripts, $this->js[self::POS_READY]);

            }

            if (!empty($this->js[self::POS_LOAD])) {

                $scripts = array_merge($scripts, $this->js[self::POS_LOAD]);

            }

            if (!empty($scripts)) {

                $this->storage->offsetSet(self::STORAGE_JS_AJAX, $scripts);

                $lines[] = Html::jsFile(['js/ajax', 'hash' => $this->hashKeys($scripts)]);

            }

        } else {

            if (!empty($this->js[self::POS_END])) {

                $this->storage->offsetSet(self::STORAGE_JS_END, $this->js[self::POS_END]);

                $lines[] = Html::jsFile(['js/end', 'hash' => $this->hashKeys($this->js[self::POS_END])]);

            }

            if (!empty($this->js[self::POS_READY])) {

                $this->storage->offsetSet(self::STORAGE_JS_READY, $this->js[self::POS_READY]);

                $lines[] = Html::jsFile(['js/ready', 'hash' => $this->hashKeys($this->js[self::POS_READY])]);

            }

            if (!empty($this->js[self::POS_LOAD])) {

                $this->storage->offsetSet(self::STORAGE_JS_LOAD, $this->js[self::POS_LOAD]);

                $lines[] = Html::jsFile(['js/load', 'hash' => $this->hashKeys($this->js[self::POS_LOAD])]);

            }

        }


        return empty($lines) ? '' : implode("\n", $lines);

    }

    

    /**

     * Generates hash based on the JS keys.

     * @param array $js

     * @return string

     */

    public function hashKeys($js)

    {

        $keys = '';

        foreach (array_keys($js) as $key) {

            $keys .= $key;

        }

        return sprintf('%x', crc32($keys . Yii::getVersion()));

    }

}




Storage component can be configured here (default session).

You need to configure the view component to use this class.




/* configuration file */


return [

    // ...

    'components' => [

        // ...

        'view' => [

            'class' => '', // fully qualified View class name as set before

        ]

    ]

];



View adds JS files with route


['js/ready']

so controller is needed.




<?php


namespace /* again change it according to your app structure */;


use yii\web\Controller;

use /* fully qualified JsAction class name (see below) */;

use /* fully qualified View class name as set before */;


/**

 * Controller renders JS via storage sent from View component.

 */

class JsController extends Controller

{

    /**

     * @inheritdoc

     */

    public function actions()

    {

        return [

            'ready' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_READY,

                'template' => "jQuery(document).ready(function () {\n{js}\n});"

            ],

            'load' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_LOAD,

                'template' => "jQuery(window).load(function () {\n{js}\n});"

            ],

            'ajax' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_AJAX

            ],

            'begin' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_BEGIN

            ],

            'end' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_END

            ],

            'head' => [

                'class' => JsAction::className(),

                'storageKey' => View::STORAGE_JS_HEAD

            ],

        ];

    }

}



This controller uses general action so here it is:




<?php


namespace /* again change it according to your app structure */;


use ArrayAccess;

use Yii;

use yii\base\Action;

use yii\di\Instance;

use yii\web\Response;


/**

 * JsAction.

 * Renders actions for JsController.

 */

class JsAction extends Action

{

    /**

     * @var string storage component key

     */

    public $storageKey;

    /**

     * @var string|array|ArrayAccess storage component

     */

    public $storage = 'session';

    /**

     * @var string output template where {js} is the code of the storage's storageKey item.

     */

    public $template = '{js}';

    

    /**

     * Ensures storage component is set.

     */

    public function init()

    {

        parent::init();

        $this->storage = Instance::ensure($this->storage, '\ArrayAccess');

    }

    

    /**

     * Renders output

     * @param string $hash

     * @return string

     */

    public function run($hash)

    {

        Yii::$app->response->format = Response::FORMAT_RAW;

        Yii::$app->response->headers->set('Content-Type', 'application/javascript; charset=UTF-8');

        $src = [];

        if ($this->storage->offsetExists($this->storageKey)) {

            $src = $this->storage->offsetGet($this->storageKey);

        }

        return strtr($this->template, ['{js}' => implode("\n", $src)]);

    }

}



This action renders JS as a standalone file.

To make it more like typical JS file we can use UrlManager.




/* configuration file */


return [

    // ...

    'components' => [

        // ...

        'urlManager' => [

            // ...

            'rules' => [

                // ...

                [

                    'pattern' => 'js/<action>/<hash>',

                    'route' => 'js/<action>',

                    'suffix' => '.js',

                ],

            ]

        ]

    ],

];



Now, every time JS code block is added via registerJs() it appears in the source of JS file - JS added with


View::POS_READY

goes to /js/ready/<hash>.js, JS added with


View::POS_LOAD

goes to /js/load/<hash>.js and so on.

CSP directives are not blocking it.

TODO:

  • caching?

All files can be found at GitHub.