Disable inline JavaScript for security

Use JS to JS template engine in Express to ban all inlined JavaScript.

The source code for this blog post is in bahmutov/disable-inline-javascript-tutorial and the demo showing the insecure page that allows inline JavaScript tags is at insecure demo. If you want to see the secure page right now, take a look at secure demo.

Definitions

First, let us define what an inline and external scripts are. An HTML page can include a script code with the code right inside the tags - this is an inline script

1
2
<p>My page</p>
<script>alert('hi there')</script>

An HTML can also include a reference to an external JavaScript file

greeting.js
1
alert('hi there');
1
2
<p>My page</p>
<script src="greeting.js"></script>

If the same tag has both src and inline javascript text, the inlined part is ignored; the external script is executed only.

1
2
<p>My page</p>
<script src="greeting.js">/*... this contents is ignored ...*/</script>

The security risk

Inserting "evil" inline script tags into a page is a common attack. If you ever allow the user-produced content to be linked into the DOM (to control its appearance for example), then your website is vulnerable to this attack. If you allow the user to enter this text

1
this is a <strong>tag</strong> and this is <em>emphasized</em>

and then insert the text as HTML into your page, very little is stopping the user from entering "bad" text like this one that have a <script> tag inside

1
2
this is a <strong>tag</strong> and my script.
Run it from your page <script>alert('You have been hacked')</script>

There are a lot of ways the <script> tag can hide inside the HTML, escaping (pun intended) even the most sophisticated text sanitizers.

You can see this in action by visiting the demo page. Clicking the green button inserts the "good" styled content. Clicking the red button on the other hand inserts the "bad" content that runs the JavaScript inside the inlined <script> tag, opening an alert box.

insecure demo

The source in the script tag can now do anything it wants - like grab the sensitive data and send somewhere, or make API calls on the user's behalf. It is pretty severe compromise at this point.

Stopping inline JavaScript completely

We can stop these attacks in their tracks by completely banning all inline JavaScript - any JavaScript code directly between the <script> ... source ... </script> tags in the page. We can still use any JavaScript that is loaded via <script src="..."></script>. The way to do this in the modern browsers is to set the 'Content-Security-Policy' (CSP) property, either via meta attribute or headers. Read these two 1, 2 references to learn about CSP. See the notes at the end of this post regarding browser support (summary: the way I use it in this blog post is widely supported by the modern browsers).

You can see a very strict and solid CSP header if you curl https://github.com website.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ curl -I https://github.com
HTTP/1.1 200 OK
Server: GitHub.com
Date: Sat, 21 Nov 2015 18:40:23 GMT
Content-Type: text/html; charset=utf-8
Status: 200 OK
Content-Security-Policy:
default-src *;
script-src assets-cdn.github.com;
object-src assets-cdn.github.com;
style-src 'self' 'unsafe-inline' 'unsafe-eval' assets-cdn.github.com;
img-src 'self'
data: assets-cdn.github.com identicons.github.com www.google-analytics.com ...;
media-src 'none';
frame-src 'self'
render.githubusercontent.com gist.github.com www.youtube.com ...;
font-src assets-cdn.github.com;
connect-src 'self' live.github.com wss://live.github.com uploads.github.com ...;
base-uri 'self';
form-action 'self' github.com gist.github.com

It is a beast, and I had split the relevant header across multiple lines to clarity. I also trimmed long lists of domains in some lines.

The most important for our purpose is the limit the GitHub places on where the scripts can be sourced: only from assets-cdn.github.com domain. Thus the scripts that are inlined in the page itself are NOT going to run. Try it now

  • Open the browser
  • Browser to https://github.com
  • Open the Developer Tools console
  • Paste the following code into the console to create a new inline script
1
2
3
var el = document.createElement('script');
el.innerText = 'alert("hi there");'
document.body.appendChild(el); // runs the code by default

You can find this code snippet in this collection.

When you paste the code snippet, it tries to run the code right away, but you get an error in the console

1
2
3
4
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src assets-cdn.github.com".
Either the 'unsafe-inline' keyword, a hash ('sha256-OsJIMy4ZGkXN5pDjr35TfT/PBETcXuD9koo2t1mYDSg='),
or a nonce ('nonce-...') is required to enable inline execution.

Basically the script we tried to execute was stopped by the browser. The browser has checked and determined that the script did NOT

  • come from the list of trusted sources assets-cdn.github.com (we executed it directly from github.com)
  • the inline script execution flag script-src unsafe-inline is not present int the CSP header.
  • the page also allows specific white-listed scripts via SHA list, but our script obviously is not on this list.

Good.

Now do the same on cnn.com, and it runs just fine.

inject script on CNN.com

So if there is user supplied content (like visitor comments), and it is not validated very well, it could potentially be linked directly into the DOM, executing the user supplied source, as if was valid code written by the CNN page developers.

Restricting the JavaScript on our page

For simplicity, we are going to set the security policy on our demo page using the meta attribute instead of the recommended way (response header 'Content-Security-Policy'). This way we can still use the static web page without running a demo server.

We can place the following into our page head element

1
<meta http-equiv="Content-Security-Policy" content="script-src https://code.jquery.com 'self';">

We are allowing the JavaScript sources to be from the following two domains:

  • https://code.jquery.com - this is were our jQuery comes from in the script <script src="https://code.jquery.com/jquery-1.10.2.js"></script>
  • self - this are all the scripts linked as external files and hosted by the current domain, like the <script src="app.js"></script>

When we try the same buttons, we can still get the styled HTML elements in the DOM (highlighted in red boxes). But the second button "Link bad input to DOM" fails to run the script (see the red arrow pointing at the error shown in the console)

secured page

You can try the page yourself here.

As a partial bonus, if you have a global error handler, like Sentry, Raygun, or your own, you can catch an error notification (but without any details, at least in Chrome 46) when a script tries to run but is prevented.

We usually inject the dynamic values into our JavaScript directly as inlined script snippets, see how to solve this problem in my blog post JavaScript to JavaScript template language.

Browser support

There are two levels of 'Content-Security-Policy' standards. Then one I have shown here, if used via a response header is widely supported. Most of the modern browsers (IE10+) support setting the list of allowed script sources link. There is a newer list of features (Content Security Policy Level 2) that allows white listing inlined source scripts by SHA, but it is only supported in a very few browsers, so I recommend against it.

Additional resources

  • Checkout this CSP header / string validator cspvalidator.org/
  • Web frameworks like AngularJS require configuration to work with CSP ngCsp
  • Vue.js framework also has a CSP-compatible build that does not use inline JS or styles. My personal website is using Vue.js with HTML fragments injected right into the DOM, CSP-compatible vue.js build allows to do this safely.

Questions

  • What happens if the attacker replaces an external script?
    • If an attacker can replace an external script served from your domain, or change a script served by a CDN then you have a big big problem. Your website is serving malware at this point and any client-side protection is useless. Secure your servers, download every external script via HTTPS.
  • Can an attacker change the meta Content-Security-Policy tag, by injecting a new one for example?
    • Yes, this is why you should include your own tag at the very top of the HEAD tag, or better send the same info using response headers.
  • Can I verify that an external script is the one I receive?
    • Use HTTPS and Subresource Integrity (SRI) where you specify SHA for the resource that you expect to receive. If CDN sends something different, the browser will refuse to run it.
  • I just need an exception for a particular inline script, can I make an exception?
    • There is the second level of content security specification (CSP2), which is not yet supported by most browsers. In CSP2 you can specify SHA for each allowed inline script. Other inline scripts will not execute be executed, since the SHAs will not be in the white list.
  • Can you lock down allowed domains more precisely?
    • Yes, instead of specifying just a domain like https://ajax.googleapis.com to serve <script src='https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js'></script> you can allow https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/ for example. Don't forget the trailing slash! You can even allow a specific file, like srcipt-src: https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js to lock everything down.