Cracking the cross-domain/Allow-Origin nut

April 13, 2012

Update/preface: None of this is relevant in IE, because IE doesn’t respect cross-origin rules. You have to use JSONP for IE (so you might as well use JSONP everywhere.)

The other day, I was setting up an Ajax feed loader - a node.js app pulling an RSS feed every few minutes and parsing it, and a Drupal site requesting a block of HTML from the node.js app via jQuery-ajax - and ran into a brick wall of cross-domain/origin (aka Cross Domain Resource Sharing) issues. This occurs any time you ajax-load something on a different subdomain or port from the main page it’s loading into. (Even if you pipe the feed through the primary domain, using Varnish for example, if you use a separate hostname for your development site, or a local server, it’ll break on those.)

In theory, it should be very simple to add an Access-Control-Allow-Origin header to the source app - the node.js app in this case - and bypass the restriction. In practice, it’s not nearly so easy.

To get at the root of the problem and eliminate quirks in the particular app I was building, I set up 2 local virtualhosts with apache, and tried every combination until it worked.

Here are some problems I ran into, and solutions, to save the next person with this issue some time:

  • Access-Control-Allow-Origin is supposed to allow multiple domains to be set - as in http://sub1.domain.com http://sub2.domain.com - but no combination of these (separating with spaces, commas, or comma+space) actually worked. The solution to this is either allow all domains with * or dynamically set the domain to the origin on a per request basis. (In a typical node.js HTTP server for example, that’s found at req.headers.origin - but that only exists if it’s called via Ajax in another page.) The latter solution is fine when the source domain is always known, or every request hits the backend, but can be problematic if you’re trying to run it on multiple endpoints, or through Varnish.
  • Chrome seems to have some bugs handling these situations, producing inconsistent results with the same environment.
  • The minimal working solution in the Apache experiment turned out to require, besides a valid Access-Control-Allow-Origin, this one header: Access-Control-Allow-Headers: X-Requested-With. (Apparently that’s used only by Ajax/XmlHttpRequest requests, and without the server explicitly allowing that request header, the request fails.)
  • Before making the GET request for the content itself, some browsers make an OPTIONS request to verify the cross-domain permissions. Several other people running into these problems recommending including this header: Access-Control-Allow-Methods: OPTIONS, GET, POST. In the Apache experiment it wasn’t necessary, but I put it in the final node.js app and it can’t hurt.
  • Also from other people’s recommendations, a more verbose version of Access-Control-Allow-Headers is possible, if not all necessary: Access-Control-Allow-Headers: Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, If-Modified-Since, X-File-Name, Cache-Control

Taking the lessons from the Apache experiment back to the node.js app, I used this code. It’s written as an express middleware function (make sure to run it before app.router or any individual routes) The _ character refers to the underscore library.

app.use(function(req, res, next) {
  var headers = {
    'Cache-Control' : 'max-age:120'   // cache for 2m (in varnish and client)
  };

  // allowed origin?
  if (!_.isUndefined(req.headers.origin)) {
    // validate (primary, secondary, local-dev)
    if (req.headers.origin.match(/domain\.com/) 
    || req.headers.origin.match(/secondary\.domain\.com/) 
    || req.headers.origin.match(/domain\.local/)) {
      headers = _.extend(headers, {
        'Access-Control-Allow-Origin': req.headers.origin
      , 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
      , 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With, X-PINGOTHER'
      , 'Access-Control-Max-Age': 86400   // 1 day
      });
    }
  }

  _.each(headers, function(value, key) {
    res.setHeader(key, value);
  });

  next();
});