Live demo showing strict content security policy banning inlined source yet having dynamic variable injection into separate JavaScript files is at js-to-js.herokuapp.com.
Problem
I have shown the benefits of disabling inline JavaScript in the previous blog post Disable inline JavaScript for security. See these two demos: unprotected and protected to see the common script injection attack and the protection in action.
Unfortunately, the shown method has a big obstacle preventing using it right out of the box.
Most of the variables are injected by the server into the page using inline scripts.
For example, an ExpressJS using Jade template engine could render a page from a view like this
1 | app.get('/', function (req, reqs) { |
The Jade page can use the variables title
and analyticsIds
to set the
corresponding values in the HTML text or JavaScript code
1 | head |
I described a typical example like these in Server-side constants injection into Angular modules, and if we disable the inline scripts, our code will stop working. How do we solve this problem?
Assume we only disable the inlined scripts. Thus our page cannot have any script
tags with code in
them. We still can have the code in the separate source files. For example we could generate on the fly
a JavaScript file with our var analyticsId = ...
code and include the reference in the page.
It should look something like this
1 | var analyticsId = '4xy-0123456'; |
1 | // included after analytics-config.js |
1 | head |
This approach will work - we do allow non-inlined scripts to run.
How should we generate a JavaScript file dynamically? It is interesting, but searching wide I could not find any template engine whose output would be JavaScript. Even a good (if obsolete) source with lots of template engines did not list any. I really want to have a source JavaScript or JSON file, with default options for simplicity and then substitute run-time values from a middleware or response callback.
This is why I wrote js-to-js - a simple JavaScript to JavaScript template engine middleware, mostly for working with ExpressJS and the like.
JavaScript to JavaScript rendering
It feels a little weird to read JavaScript and render another JavaScript - but this is necessary because we need dynamic server-side rendered scripts. For the example above, we can now do the following steps.
Write a CommonJS module with default configuration and place it in the views
folder of
the server. For example, we can place the following in views/js/analytics-config.js
1 | module.exports = { |
At runtime, the server will transform this file by substituting the actual analytics ID (taken from config or environment, I recommend using nconf for this) and producing another file on the fly that will be returned to the client.
The server is setup like this
1 | var express = require('express'); |
Our page requests js/analytics-config.js
before request actual analytics script
1 | <script src="js/analytics-config.js"></script> |
and at runtime, the client will receive
1 | // generated for js/analytics-config.js |
Notice that the basename of file js/analytics-config.js
was taken ('analytics-config')
and then transformed using kebab case to the new variable name inside
var analyticsConfig = ...
statement;
Fixing other code snippets
A lot of code snippets from 3rd parties are distributed by default using inline script commands, for example, here is the default Google Analytics code snippet given to the website admins to insert into their HTML
1 | <script> |
This script will no longer execute directly from our page! What do we do? Well, first
of all, our server should inject the tracking id dynamically. Second, there is nothing
magical about this snippet, and nothing prevents us from placing in its entirety
into the views
script to function as the input. The js-to-js rendering
engine will detect if this is a function and will call it with options argument.
We can wrap the above 3rd party snippet in a function expecting an object, but now
instead of hard coded tracking id, it will use the property of the options
argument.
1 | module.exports = function initGoogleAnalytics(options) { |
Then store this snippet in the views/js
folder and render it in response to
a request, providing a the actual tracking id as a property
1 | // express server |
The js-to-js template engine automatically treats functions differently and wraps them in a closure passing the runtime options. Thus the google analytics script will execute something like this when the client requests this page
1 | ;((function initGoogleAnalytics(options) { |
Do not forget to add the https://www.google-analytics.com
to the list of allowed
script sources in the 'Content-Security-Policy script-src' list.
I like using the options object. It is a lot more manageable in the long run than passing separate arguments. If you need schema validation with the options, take a look at check-more-types.schema method. If you need partial application that works with objects, take a look at obind function.
Conclusions
You can see the live result at js-to-js.herokuapp.com.
As you can see, the inline script that tried to overwrite the config was disabled,
while we could inject dynamic values in two ways. First, we injected an object and changed
a property before the second script ran, using the {"analyticsId":"4xx-xxxxx"}
object.
Second, the Google Analytics injection worked by wrapping their snippet and executing
with an options object that passed a dynamic value for property googleAnalyticsId
.
This worked fine, because we can see the tracking call to the https://www.google-analytics.com/collect
endpoint. If you inspect this GET request, you will see the id we injected being used:
id=this-is-a-demo
is part of the query.
Using the approach explained in this blog post we can keep unsafe inline JavaScript turned off on our pages, while still relatively easily injecting dynamic configuration into our scripts.
As the last note, remember that this injection is only necessary for JavaScript fragments. You can still inject regular values directly into the page. For example, you can still inject dynamic title using Jade template or the list of allowed CDN hosts
1 | head |
Only the script code has to be external to the page.