CSRF Protection in Plug and Phoenix

When I encounter a new class of vulnerability or attack vector, I typically add a check for it in Sobelow. Occasionally, however, I see an opportunity for these issues to be mitigated at the library level. In these cases, I try to reach out to José or Chris to talk through potential solutions.

For example, I recently ran into the following issue:

<%= form_for @conn, "https://third-party-site", fn f -> %>
  <%= email_input f, :email %>
  <%= submit "Get notified!" %>
<% end %>

A developer building SiteA added a form for a third-party service (SiteB), using Phoenix’s built-in form_for function. Because Phoenix forms include a valid CSRF token by default, these tokens were being leaked to the third-party1. As a consequence, SiteB could cause a user to perform unwanted actions on SiteA, such as updating their password or deleting their account.

This is the problem we were aiming to fix. But before getting to our solution, I want to flesh out the problem a little bit. Let’s start with an overview of Cross-Site Request Forgery (or CSRF), one of the web’s most common vulnerabilities.

What is CSRF?

The basic idea is this: Any website can create a form pointing to any other website. They can also automatically fill out and submit these forms. Because cookies are sent with every request, a CSRF attack allows untrusted applications to cause a user’s browser to submit requests and perform authenticated actions on the user’s behalf. Thankfully, web libraries such as Phoenix and Plug have adopted techniques to mitigate such attacks.

The most common solution to this problem, and the one taken by Phoenix, is to store a unique, random token in the user’s session. This token is then fetched by the client and sent along with every request. When an application processes the request, the submitted token should match the token stored in the user’s session. This way, a malicious website can only cause a user to make valid requests if they have access to a valid CSRF token.

Now, this is where our initial issue comes into play. When using form generation for third-party or dynamic endpoints, valid CSRF tokens will be leaked by default, leaving users vulnerable to attack. This is something that an experienced developer will probably catch, but it’s a common enough occurrence that it would be nice if the issue could be addressed.

So, the core issue is simple: In some cases, valid CSRF tokens are leaked by default. And the desired outcome is clear: Don’t leak valid tokens by default. But, in achieving that solution, there are a few considerations:

  1. Tokens leaked in this manner shouldn’t be useful to an attacker.
  2. A solution shouldn’t increase burden on the developer, and it shouldn’t sacrifice performance.
  3. Tokens should work across domains when desired.

What is the solution?

Ultimately, the solution is actually pretty simple. If the token is being generated for a path, as is typical, then everything stays the same. That is, fetching the CSRF token looks something like this:


However, if a token is fetched for a fully qualified host, it looks more like this:

csrf_token = get_csrf_token()
key = KeyGenerator.generate(secret, csrf_token)
host_token = MessageVerifier.sign(host, key)

The user’s CSRF token is fetched like normal. However, instead of returning the token directly, the token is used as a “salt” in a key derivation function. This newly derived secret key is then used to sign a message which includes the target host.

On the receiving end of things, the CSRF token’s signature is validated. If the signature verification succeeds, then the host is validated to ensure it matches a set of allowed hosts (or the host header). If either of these checks fails, the request is rejected as fraudulent.

Let’s look back at the initial problem, this time using our new host tokens.

A developer building SiteA adds a form for a third-party service (SiteB), using Phoenix’s built-in form_for function. Because Phoenix forms include a CSRF token by default, these tokens will be leaked to the third-party. Fortunately, these are now the signed host tokens. If the malicious site attempts to initiate a CSRF attack, the host signed in the token (SiteB) won’t match the host being requested (SiteA). As a consequence, SiteB’s attack fails.

This technique has a few benefits. First, and most importantly, nothing changes in the vast majority of cases. Most forms will continue to include plain, standard CSRF tokens. But now, if CSRF tokens are leaked in the described manner, we are safe from attack. Further, unlike other potential solutions, this solution will still work seamlessly across subdomains and domains the developer controls.

With this solution, we prevent usable tokens from leaking by default, require no changes from the end-developer, and still allow the same range of functionality.

These changes are live on Plug 1.5.0 and phoenix_html 2.10.0!

  1. This is not a problem unique to Phoenix. You will encounter it in any web framework with form generators that include a CSRF token.