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 | <p>My page</p> |
An HTML can also include a reference to an external JavaScript file
1 | alert('hi there'); |
1 | <p>My page</p> |
If the same tag has both src
and inline javascript text, the inlined part is ignored; the external
script is executed only.
1 | <p>My page</p> |
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 | this is a <strong>tag</strong> and my 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.
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 | $ curl -I https://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 | var el = document.createElement('script'); |
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 | Refused to execute inline script because it violates the following |
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 fromgithub.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.
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)
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 allowhttps://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/
for example. Don't forget the trailing slash! You can even allow a specific file, likesrcipt-src: https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js
to lock everything down.
- Yes, instead of specifying just a domain like