Testing Around CSRF Protection

Tests broke when we added blanket CSRF protection. Requests that had slid through easily before became forgeries, “OK” responses became unauthorized, and a cheerful green test suite glowed an angry red.

“Aha!” we thought, “we can just disable protection in our test environment.” And we could, though it would leave our tests proving different code than we would run in production. Clearly less than ideal.

A better way

CSRF protection mechanisms are hardly novel. When we make a request, the response includes a token allowing us to complete some action. If we proceed with that action–submitting a form, say, or sending an XHR–we include the token as verification. Nothing stops us from repeating this manual process in our tests: before testing an action, we can take some other step to acquire a valid CSRF token. Include it in the action, and voilà! What was red is green again.

Consider the process of registering an account. Given a registration form (GET /register) that can be submitted (POST /register), we will:

  1. create a session with the application
  2. request the form
  3. extract a CSRF token for the session from the response
  4. submit the form using the same session and the newly-acquired token
  5. assert that the results match expectation

For demonstration, we’ll assume that we’re testing a node.js application protected by a CSRF utility like the one bundled in lusca. On the test side, we can use supertest-session to manage persistent test sessions and cheerio for token extraction. These are far from the only tools available for each step of the process, but the general idea should stay the same no matter what libraries (or even languages!) are in use.

Create a session

Since the token we will be retrieving is particular to the active session, we will need to be sure to use the same session for both generating the token and running our tests. For standard, cookie-based sessions, supertest-session gives us an easy wrapper around a persistent cookie jar:

// test/spec.js
var Session = require('supertest-session')({
  app: require('../app.js')
});

describe('POST /register', function () {

  var session;

  beforeEach(function (done) {
    session = new Session();
  });

  // ...
});

Now, all requests made through our session will appear as the same session to the application we’re testing.

Extracting the token

In some cases, it might make sense to expose CSRF tokens within an HTTP header sent back from the server. In this case, extraction is easy–simply check the header of the ServerResponse and get on with the test.

In our form-based example, however, the token will be locked away in an <input> tag inside the form. There, we can retrieve it using any of a number of DOM-parsing libraries or any clever scheme we can devise for looking up HTML attributes. Opting for the former, we can write a quick helper that uses cheerio to extract the token value:

var cheerio = require('cheerio')

function extractCsrfToken (res) {
  var $ = cheerio.load(res.text);
  return $('[name=_csrf]').val();
}

The specific selector used to retrieve the token will vary depending where the token is store, but–once the lookup completes successfully–we will have the valid token sent in response to our GET request:

describe('POST /register', function () {

  // ...

  var csrfToken;

  beforeEach(function (done) {
    session.get('/register')
      .end(function (err, res) {
        if (err) return done(err);
        csrfToken = extractCsrfToken(res);
        done();
      });
  });

  // ...
});

Writing the test

With session and token in hand, it’s time to make the test pass. The only trick is that the request must be made through our session: for the token, we need only include it in the submitted form data like any other field. Inside a context with access to session and csrfToken, we can now provide valid responses to our CSRF protection scheme:

// tests....
it('should accept the result', function (done) {
  session
    .post('/register')
    .send({
      _csrf: csrfToken,
      username: 'foo',
      password: 'bar'
    })
    .expect(201)
    .end(done)
});

And there it is! Token sent. Tests pass. Ship it.

Epilogue

It will take longer to run a test suite with twice as many requests. For a large suite with thousands of tests, an extra request just to receive a token may have a significant impact. And depending on the application and the test, blanket CSRF behavior might not even be a necessary detail. It might be reasonable to simply disable protection outright during testing, or to consider a test-only “magic” value acceptable across the test environment. If we depend on representative tests, however, an extra request will be well worth the time.


Example project available on github.

Featured