Phoenix CSRF protection in HTML forms, React forms, and APIs

Let’s see how Phoenix implements CSRF for standard HTML multipart forms and how to use CSRF tokens outside these forms in React forms or API calls.

Cross-Site Request Forgery (CSRF) is exactly what it sounds like – an ability to do make a request on behalf of a user from a different site. This is possible when the authorization of the request derives from the authentication that happened automatically by the browser. There are two common scenarios for this; session cookies and HTTP Basic scheme authentication. In both cases the browser sends the authentication details (cookie or login/password combination) with every request to the particular domain name automatically.

To make it clear, CSRF is about the malicious request itself, not about the ability of the client to get the response. That’s why we don’t talk about preventing GET request with CSRF protection (and potentially leaking sensitive content). GET requests should not be changing state of the server (e.g. requesting a transfer of money, or placing an order).

The best solution we have today to combat this vulnerability are CSRF tokens. There is also an option to use Same-Site Cookie Attribute in some cases (which will be great for cookies in future). In this post we discuss CSRF tokens that should be sent with every non GET request and validated by the server. If the validation fails or the token is not provided, server should respond with 403 Forbidden.

To see how this protection works out-of-the-box in Phoenix let’s generate a new Todo application:

$ mix phx.new csrf
$ cd csrf
$ mix phx.gen.schema Todo todos description
$ mix ecto.create
$ mix ecto.migrate
$ mix phx.gen.html Web Todo todos description

And open the form that allows to submit a new task:

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :description %>
  <%= text_input f, :description %>
  <%= error_tag f, :description %>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

Looks like a regular form definition and nothing about CSRF in there. However, let’s see the HTML output:

<form accept-charset="UTF-8" action="/todos" method="post">
  <input name="_csrf_token" type="hidden" value="Cm4mDW46IzBPJRZWGwpVOxskYgAJEAAAH7tiAvuVyqW4spcKto5OfQ==">
  <input name="_utf8" type="hidden" value="✓">  

  <label for="todo_description">Description</label>
  <input id="todo_description" name="todo[description]" type="text">

  <div>
    <button type="submit">Save</button>
  </div>
</form>

Phoenix automatically injected the CSRF token for us inside the form and it will be sent together with other data on form submission. Sending the token means nothing of course if we don’t validate it. If we open the router definition we can see there :protect_from_forgery plug which Phoenix rightfully suggests as part of the :browser pipeline:

pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
end

pipeline :api do
  plug :accepts, ["json"]
end

:protect_from_forgery is actually just a wrapper for Plug.CSRFProtection and as the description say The token may be sent by the request either via the params with key “_csrf_token” or a header with name “x-csrf-token“.

Important option of this plug is :with which can be either :exception or :clear_session to either raise and stop the request or just clear the session for a given request. If we are using :protect_from_forgery plug we can pass it the same options.

By default, it raises exception which is the correct thing for handling HTML form submission like above. If we would like to be doing AJAX request then we would prefer to just cancel the session temporarily instead.

So let us do exactly that with a React form instead of the classic HTML form generated by Phoenix. First, we need to be able to grab the token from somewhere. Since we are still generating the page with Phoenix we can use csrf_meta_tag() function to insert a meta attribute containing the CSRF token:

<!DOCTYPE html>
<html lang="en">
  <head>
    <%= csrf_meta_tag() %>

This is how it will look like once the page is generated:

<head>
  <meta charset="UTF-8" content="ERBqDnUmHgtgHhQWR0gOOCIMeAVXEAAASI8jZjHmVJUt/28HMG/J8Q==" csrf-param="_csrf_token" method-param="_method" name="csrf-token">

Next, let’s add React dependency and write the form:

$ npm install --save react react-dom @babel/core @babel/preset-react
// assets/js/app.js
...
import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class ReactForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    event.preventDefault();

    var value = this.state.value
    var csrfToken = document.head.querySelector("[name~=csrf-token][content]").content;

    // AJAX request
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/todos');
    xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    xhr.setRequestHeader('X-CSRF-Token', csrfToken);

    xhr.onload = function() {
      if (xhr.status === 200) {
        alert('New todo created, refresh the page to see it');
      }
      else if (xhr.status !== 200) {
        alert('Request failed.  Returned status is ' + xhr.status);
      }
    };

    xhr.send(encodeURI('todo[description]=' + value));
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Description:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

ReactDOM.render(<ReactForm />, document.getElementById('reactForm'))

Notice how are we grabbing the token and adding it to the AJAX request:

var csrfToken = document.head.querySelector("[name~=csrf-token][content]").content;
...
xhr.setRequestHeader('X-CSRF-Token', csrfToken);

We could also change the plug now to clear session:

# in router
plug :protect_from_forgery, with: :clear_session

This would enable us to respond with some custom JSON that we could use in the React form for example, but I will skip it now.

At last, what if we don’t have the luxury of generating the HTML page with Phoenix? Let’s pretend our React form is actually part of a separate Single Page Application. For this reason, we have to offer the token as part of the API:

defmodule CsrfWeb.TodoController do
  use CsrfWeb, :controller

  alias Csrf.Web
  alias Csrf.Web.Todo

  def csrf_token(conn, _params) do
    # There is also get_csrf_token_for(url)
    csrf_token = get_csrf_token()

    send_resp(conn, 200, csrf_token)
  end
...

We can generate the CSRF token inside any controller if we use the CsrfWeb module and then simply call get_csrf_token() or get_csrf_token_for(url) functions. Once ready we can alter the React form to first get the token and then include it:

handleSubmit(event) {
    event.preventDefault();

    var value = this.state.value
    var csrfToken = ""

    var xhr = new XMLHttpRequest();
    xhr.open('GET', '/csrf_token');
    xhr.onload = function() {
      if (xhr.status === 200) {
        csrfToken = xhr.responseText;
        console.log(csrfToken);

        // AJAX request
        var xhr2 =new XMLHttpRequest();
        xhr2.open('POST', '/todos');
        xhr2.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        xhr2.setRequestHeader('X-CSRF-Token', csrfToken);

        xhr2.onload = function() {
          if (xhr.status === 200) {
            alert('New todo created, refresh the page to see it');
          }
          else if (xhr.status !== 200) {
            alert('Request failed.  Returned status is ' + xhr.status);
          }
        };

        xhr2.send(encodeURI('todo[description]=' + value));
      }
      else if (xhr.status !== 200) {
        alert('Requesting CSRF token failed.  Returned status is ' + xhr.status);
      }
    };
    xhr.send();
  }

That’s it! We have protected classic forms, AJAX forms, and forms in SPAs! Please note that the JavaScript code is far from perfect :).

Leave a comment

Your email address will not be published. Required fields are marked *