Okay,

based on my previous article, we did run into some troubles due the way Warden, Devise and Rails handle custom HTTP Headers that we did not anticipate. After digging deep into the bowels of the frameworks, we finally came to a solution that seems to be working for us and is also testable, or so we think at least.

What changes did we make?

The first, and most important change we had to make was the usage of the headers. We originally relied on the HTTP_AUTHORIZATION header being supplied from clients that wanted to use our application as an API.

Devise and Warden rely on this header for supporting BasicAuth, as does Rack. Add into the mix that Rack prefixes all custom headers with HTTP_, we ran into some weird situations with our clients.

So, to keep using the headers, and making sure we had no conflict with the existing frameworks, I decided to prefix them with our company name as well to create a unique namespace.  ( Who would have known that there’s best practises, right )

So from now on we use custom HTTP_RISKMETHODS_ headers for transmiting our information from the mobile companion applications. Of course we wanted to make sure that this was actually working, so we were looking to write some tests with RSpec for this to capture the behaviour.

RSpec, custom headers and Rack

Now if you thought that figuring out this entire CSRF stack was difficult, wait till you’re actually wanting to test this using RSpec….

The initial problem is that none of the specs actually support CSRF. By default, this behaviour is disabled in the test-environment because you would have to make GET requests first to get the token, and then inject it in every POST request you would make in all your controller related tests. Sufficiently to say, that’s quite the hassle to simply test controller logic.

However, the tests we wanted to write HAD to simulate this behaviour. We explicitly depend on the CSRF check to fail because everything is being submited/requested from a third-party and not the Rails application.

So the first step in our test suite:

before(:each) do
  ActionController::Base.allow_forgery_protection = true
end

after(:each) do
  ActionController::Base.allow_forgery_protection = false
end

The above snippet enables and disables the CSRF checks for every test. That way we can be sure the setting does not affect any of the other tests we have. If you want to see the consequence of this, simply enable it one of your specs and observe how they suddenly all start failing.

Feature test with Capybara

The first test I wrote was a complete feature spec in RSpec to simulate the high-level usage of the mobile companion applications. The problem however is that these tests are driven by Capybara, and sending out custom headers with Capybara is actually quite a pain.

What I did was create different drivers for the various test-cases and simply switch to them during each test to make sure that the request would properly simulate the behaviour I wanted to test:

::Capybara.register_driver(:apple_tv_driver) do |app|
  timestamp = Time.now.to_i

  ::Capybara::RackTest::Driver.new(
    app,
    headers: {
      VERSION_HEADER => VERSION,
      AUTH_HEADER => generate_secret_hash,
      TIMESTAMP_HEADER => timestamp,
      'User-Agent' => 'Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)',
      'Content-Type' => 'application/json'
    }
  )
end

::Capybara.register_driver(:apple_tv_driver_missing_auth) do |app|
  timestamp = Time.now.to_i

  ::Capybara::RackTest::Driver.new(
    app,
    headers: {
      VERSION_HEADER => VERSION,
      TIMESTAMP_HEADER => timestamp,
      'User-Agent' => 'Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)',
      'Content-Type' => 'application/json'
    }
  )
end

::Capybara.register_driver(:apple_tv_driver_missing_timestamp) do |app|
  timestamp = Time.now.to_i

  ::Capybara::RackTest::Driver.new(
    app,
    headers: {
      VERSION_HEADER => VERSION,
      AUTH_JEADER => Digest::MD5.hexdigest("rarodefault@riskmethods.nettonga#{timestamp}"),
      'User-Agent' => 'Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)',
      'Content-Type' => 'application/json'
    }
  )
end

With these drivers in place, our tests started to look like this:

context 'Missing Headers' do
  context 'AUTH header is missing' do
    it 'prevents requests being made to the application' do
      ::Capybara.current_driver = :apple_tv_driver_missing_auth

      # Authenticate first, to get the cookie set properly
      ::Capybara.current_session.driver.submit(:post, '/users/login.json', parameters)

      # This should have failed
      expect(page.status_code).to eq(403)
    end
  end

  context 'TIMESTAMP header is missing' do
    it 'prevents requests being made to the application' do
      ::Capybara.current_driver = :apple_tv_driver_missing_timestamp

      # Authenticate first, to get the cookie set properly
      ::Capybara.current_session.driver.submit(:post, '/users/login.json', parameters)

      # This should have failed
      expect(page.status_code).to eq(403)
    end
  end
end

Now we have a feature spec that simulates a third-party client by submitting custom headers using the Capybara driver.

Request specs with Rack::Test

The second test we wanted was a RSpec Request test. These are a different kind of high-level testing that is available within the RSpec suite. However, I ran into so much problems with this kind of test that I had to manually include Rack::Test::Methods to get the functionality I was looking for:

def login
  inject_request_headers
  post('/users/login.json', login_params.to_json)
  expect(last_response.status).to eq(201)
  expect(rack_mock_session.cookie_jar['_riskmethods_session']).to be_present
end

# Performs a Logout request against the application using the Riskmethods TV Headers
def logout
  inject_request_headers

  delete("/users/sign_out.json?device_token=#{device_token}")

  expect(last_response.status).to eq(204)
end

# Sets the headers needed for a request using the Rack::Test implementation.
# The feature attribute can be used to set custom scenario's for headers.
def inject_request_headers(feature: :default)
  headers.each { |header, value| header(header, value) } if feature.eql?(:default)
  headers.merge('VERSION' => '2').each { |header, value| header(header, value) } if feature.eql?(:wrong_version)
end

These three methods use the methods defined by Rack::Test to perform a login request, a logout request and inject the custom headers any third client needs to submit. As you can see, they are completely different from standard RSpec tests due the methods being used and the hacks to get the setup I want.

Important: Testing like this is strange. Rack::Test automatically pefixes any custom header with HTTP_, so make sure you define the right headers!

Our specs themselves now looked like this:

it 'returns a valid response when the Apple TV headers are present' do
  inject_request_headers

  get('/')

  expect(last_response.status).to eq(200)
end

it 'returns a forbidden when the Apple TV headers are incorrect' do
  inject_request_headers(feature: :wrong_version)

  get('/')

  expect(last_response.status).to eq(403)
end

Conclusion

I’ve had to make so many changes to the way that the application works, it becomes hard to keep track of what proper CSRF protection actually means.

In a short summary:

  • protect_from_forgery enables CSRF protection. Rails recommends to disable this for API controllers, but we infortunately do not yet have dedicated API controllers, so we keep it enabled.
  • Every POST, PUT or DELETE that does not include the authenticity tokens generated during a GET request, will trigger the handle_unverified_request
  • Devise overwrite handle_unverified_request as well!
  • Devise has an around_action that allows authentication to succeed when posting parameters to the controller.
  • Devise::SessionsController does inherit from your ApplicationController by default. Careful what you define there.
  • Do not use standard HTTP headers for custom behaviour. Warden doesn’t play nice if you do.

I hope this piece of information sends people on the right path when having to deal with CSRF, API requests and RSpec.

Advertisements