XSS and How to Escape

Some time ago I wrote on cross-site scripting and proper escaping in EJS templates. I expanded the topic and presented on it today at the Salt Lake City Front End Users Group + Donuts.js. Here I stripped out the getting to know you slides and uploaded it to SlideShare.

The examples are in EJS but the ideas are universal.  Hopefully, this is one step closer to a perfect presentation on the subject. The topic can be tricky even though the solution is simple.  There are just so many attractive wrong ways to do it.

The presentation has several GIFs in it that really just add some fun. You can get all the meat by viewing the slideshow online. Or download it and have a laugh. (Check out the presenter notes if you do download it.)

Will EJS Escape Save Me From XSS? Sorta

If you’ve never had your website reported for cross-site scripting (XSS) vulnerabilities then you’re missing out. Of course, it’s great to get it right the first time. But it’s hard to beat that sense that you’re wide open for attack, it’s your fault, and everyone knows it thanks to some white-hat hacker.

This raises the question how to generally protect against XSS. Of course, there are a lot of ways to screw up. Here’s one of them.

Here’s Your Broken Code

You have a value on the server (like locale) that you want accessible on the client. You realize that you’re building the whole page in EJS anyway so why not plop a script tag on the page and pop a var into it? So, we render it right into some JavaScript like this:

var lang = "<%- locale %>";

Here’s the problem: What if locale‘s value is en"; doEvil(); "throw away string literal? Now we render into a JavaScript execution context the following code

var lang = "en"; doEvil(); "throw away string literal";

Which is valid AND EVIL code.

Does <%= Do the Necessary Escaping? Erm…

What if we use the escaping capability of EJS? Are we safe? Sorta.

Let’s bust out the REPL.

$ node
> var ejs = require('ejs')
> var locale = 'en"; doEvil(); "throw away string literal'
> ejs.render('var lang = "<%- locale %>";')
'var lang = "en"; doEvil(); "throw away string literal";'
> ejs.render('var lang = "<%= locale %>";')
'var lang = "en&#34;; doEvil(); &#34;throw away string literal";'

You see that using the back fat arrow (<%=) does prevent the evil from running in this case. But it isn’t really a safe technique in general.

What if you had a number instead of a string? What if you wanted to do the same thing to it? Continuing in the REPL the sample attack would look like this:

> var onServer = '6; doEvil();'
> ejs.render('var count = <%= onServer %>')
'var count = 6; doEvil();'

Notice that the escaping doesn’t help because there are no quotes in the attack string. In order for escaping to really work it would have to escape semicolons, too.

So, you’re kinda safe as long as you are using strings OR at least match the untrusted string with a RegEx like /[^;'"]*/ and use the matched text instead of the full text.

My Tools Have Betrayed Me!?

Why is EJS so broken? Why doesn’t escaping help you escape?

It isn’t broken. The problem is that back fat arrow is an HTML escape and you are rendering text into a JavaScript execution context.

For escaping to be reliable you have to match data context with escaping algorithm.

In this case the context is JavaScript and the algorithm is HTML. Close. But missed it by that much.

What’s the Right Way?

The Right Way™ to do this is to render it into a meta tag like this:

<meta name="lang" content="<%= lang %>">

Notice that here the escaping algorithm (HTML) matches the data context (HTML).

Then you get the value using code like this:

var metas = document.getElementsByTagName('meta');
var i, l = metas.length, lang;

for (i=0; i < l; ++i) {
  if (metas[i].getAttribute('name') == 'lang') {
    lang = metas[i].getAttribute('content');

Looking at the Right Way™ it’s no wonder that we take shortcuts.

But seriously, the Right Way™ is much less XSS error prone.

For other ideas on how to get meta data from the DOM using JavaScript you can always Stack Overflow.