Using CORS policies to implement CSRF protection

A modern alternative to CSRF tokens

Tuesday, Jun 13th, 2017

Mixmax is a communications platform that brings professional communication & email into the 21st century.

This post is a follow-on to our CORS post back in December. We'll describe how traditional CORS policies aren't sufficient defense against cross-site request forgery (CSRF) attacks, and unveil a new Node module that layers CSRF protection on top of such policies, cors-gate. We'll show how an Origin-based approach has fewer moving parts than CSRF, and pairs neatly with CORS to protect your users against CSRF attacks. Note that this approach depends on modern browser functionality, and will not work if you're targetting older browsers.

Using the Origin and Referer headers to prevent CSRF

Cross-Site Request Forgery (CSRF) allows an attacker to make unauthorized requests on behalf of a user. This attack typically leverages persistent authentication tokens to make cross-site requests that appear to the server as user-initiated. Prior to our mitigation, a user visiting a third-party website while logged in to Mixmax could allow that website to make unauthenticated requests. Using CSRF, that website could execute actions with the user's Mixmax credentials.

We previously discussed using CORS to secure user data, while allowing some cross-origin access. CORS handles this vulnerability well, and disallows the retrieval and inspection of data from another Origin. Without the cooperation of Mixmax servers, CORS will prevent the third-party JavaScript from reading data out of the image, and will fail AJAX requests with a security error:

XMLHttpRequest cannot load https://app.mixmax.com/api/foo. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://evil.example.com' is therefore not allowed access.

Moreover, for preflighted requests, the browser will make an OPTIONS request prior to making the actual data-laden request, so the server won't have a chance to perform the unauthorized request. For simple requests, however, the browser makes the preflight request and simply disallows reading data from the response if the server does not give explicit permission. If the server isn't careful, though, it can still process the request.

Until recently, Mixmax was vulerable to cross-site request forgery in spite of our existing CORS implementation. CORS, after all, does not restrict access to data, but instead instructs the browser to specifically allow access to responses from cross-origin requests. As such, while it accidentally disables some cross-origin actions (by nature of the OPTIONS preflight), it does not block all requests. A user visiting a third-party site could have that site send an email on their behalf, log them out, or even log them into an attacker-controlled account. Even safe GET requests, which per the HTTP specification should not cause side-effects, can unnecessarily consume resources and may represent a denial-of-service (DOS) attack vector.

There are a variety of ways to defend against such attacks. The simplest is to check that the request originates from a trusted site, using the Origin request header. This is also the top solution recommended by the OWASP.

The Origin header is the same header examined by the cors Node module when adding CORS response headers. However, such modules generally stop short of failing requests, as a matter of complying with the CORS specification and separating the concerns of allowing vs restricting access. Per cors' maintainer, "a CORS failure should look exactly as if the server has no idea what CORS is - in which case the request will still go through," (just without the CORS headers).

Another reason CORS modules avoid implementing CSRF protections is that browsers may not send the Origin header, as in the following cases:

However, in all of these cases, the browser does send a Referer header. We can make use of the Referer header, which browser-initiated requests may not spoof, in place of the missing Origin header.

We have an additional constraint: because we identify the current user by the user query parameter like user=foo@example.com, we have to make sure the Referer doesn't leak to third-party websites. We can prevent such leaks with the relatively new Referrer-Policy header. When sent by our services, this header governs the conditions under which the browser may expose referrer data to the same or other services within HTTP requests. We would ideally set a policy of strict-origin, as this gives us the requester's origin without any sensitive path information and without exposing the user's browsing history over an insecure connection.

Chrome does not support strict-origin as of 06.12.2017. Its implementation only allows no-referrer (and thus no origin, in cases where Origin is not available), no-referrer-when-downgrade (exposes path information), and origin-when-cross-origin, same-origin and unsafe-url, all of which leak referrer data over HTTP. We chose the best available option, no-referrer.

Safari doesn't support Referrer-Policy at all, but rather an older draft of the specification with never, always, origin, and default values, the latter being equivalent to no-referrer-when-downgrade. We again choose the best available option, never, to avoid leaking referrer data over HTTP.

Thankfully, Firefox does support strict-origin. This lets us accomplish the crucial goal of preventing CSRF attacks while preserving permissible same- and cross-origin access. When Chrome and Safari add support for strict-origin, we can prevent unauthorized cross-origin access even to GET requests.

During our implementation, we came across a final quirk of Chrome's implementation: the Referrer-Policy has unintended consequences on form-submitted POST requests. As of Chrome 58, the no-referrer policy makes form POSTs send Origin: null for both same- and cross-origin POSTs. Safari sends the correct Origin header regardless of the presence of the meta referrer. With Referrer-Policy set to strict-origin, Firefox sends no Origin header, but does send the Referer.

cors-gate

To implement this defense, we published cors-gate, a module which halts request processing when the request does not definitively originate from a trusted domain. To best interoperate with our existing CORS middleware, cors-gate comes after the cors module, and reads the response headers to determine whether the request should be allowed per CORS. The module also checks the Origin against the server's current origin, as defined at startup. The middleware permits all safe requests (GET, HEAD) by default, as we cannot reliably determine their Origin, and they should have no side effects.

This snippet sets up an Express app to permit cross-origin requests from https://app.mixmax.com and https://other-app.mixmax.com to https://api.mixmax.com. Requests from other origins will fail outright, while requests from https://api.mixmax.com (same-origin requests) will continue to function.

const cors = require('cors');
const corsGate = require('cors-gate');

// if the Origin header is missing, infer it from the Referer header
app.use(corsGate.originFallbackToReferrer());

// the expressjs/cors module
app.use(cors({
  origin: ['https://app.mixmax.com', 'https://other-app.mixmax.com'],
  credentials: true
}));

// prevent cross-origin requests from domains not permitted by the preceeding cors rules
app.use(corsGate({
  // require an Origin header, and reject request if missing
  strict: true,
  // permit GET and HEAD requests, even without an Origin header
  allowSafe: true,
  // the origin of the server
  origin: 'https://api.mixmax.com'
}));

As a consequence of our chosen microservices architecture, clients and other microservices may make requests to the same endpoint. Moreover, we have an API gateway that enables third-party developers to build atop our platform, and we wish to allow cross-origin requests for third-party clients - provided they do not depend on the user being authenticated on our domains. To these ends, instead of immediately halting requests from unauthorized domains, we force require these requests to authenticate with an API-token, instead of with a cookie. Server-to-server and permitted third-party client-to-server requests can thus continue to operate, enabling their interaction with our services.

Alternatives for mitigating CSRF

We chose to implement a new module to mitigate CSRF attacks. Alternative solutions to CSRF protection also exist.

To run down https://github.com/pillarjs/understanding-csrf as of 06.13.2017:

Use only JSON APIs (vs. e.g. application/x-www-form-urlencoded)

The unspoken assumption of this guideline is that a Content-Type header of application/json will trigger CORS preflighting, and if you haven't enabled CORS, the browser won't issue the actual request. This is all well and good except for that navigator.sendBeacon doesn't trigger preflight requests.

This solution is also undesirable for APIs where you do want to enable CORS, since preflighting will make your trusted requests slower.

Disable CORS

As discussed in the overview, this will just prevent client-side JavaScript from accessing the response to a malicious request. It doesn't necessarily stop the server from responding, which is the essence of CSRF attacks.

Check the Referer header

This is almost equivalent to the proposed solution, but is incompatible with servers that set the Referrer-Policy header to protect their referrer data. By contrast, Referrer-Policy: no-referrer does not suppress the Origin header for AJAX requests.

GET should not have side effects

This is good advice regardless of CSRF, especially because no browser sends the Origin header with same-origin GET requests.

"Avoid using POST" and "don't use method-override!"

Like the "use only JSON" rule, these guidelines assume that requests will hit preflighting and fail. In this case, fair enough, since navigator.sendBeacon only uses POST. It is onerous to avoid using POST, though.

Don't support old browsers

Our proposed solution relies on this.

CSRF tokens

Depending on how CSRF tokens are implemented, they can indeed be extremely robust, and address the disadvantages of cors-gate by locking down same-origin GET requests.

We determined that this disadvantage is tolerable, so we prevent CSRF by using the simpler proposed solution. CSRF tokens introduce additional complexity for both clients and servers, and add non-negligible overhead due to their reliance on additional state.

Acknowledgements

Thanks to Jeff Wear for his diligent background research.

Thanks to Douglas Wilson for his work in maintaining Express' cors module and informing the development of cors-gate.

Interested in creating simple solutions to complex security problems? Join us!