Testing IP Whitelisting in your Specs and Features
Rails has so much support for testing built into itself that its rare I come up with something that’s hard to test but HTTP headers is not easy. Normally you don’t have to worry about HTTP headers as they’re set by the browser and you don’t do much with them. Recently I was working on an application where each user has an IP whitelist and they are only allowed to come from their whitelisted IP addresses. This isn’t as crazy as it sounds since the app is in a corporate environment and the users will all be coming from their corporate networks.
Basically this means our authentication method needs 3 pieces of information
- username
- password
- remote ip address
What makes this interesting is that the first two are input by the user but the ip address comes from the browser and network. Writing an RSpec unit test or Cucumber scenario to test user parameters (username and password) is something we’ve all done before but today I’m going to talk about how you can also test the IP address in a header.
Implementation
Before we look at how to test this let’s take a look at the implementation of our SessionController
.
class SessionsController < ApplicationController
def new
@session = Session.new
end
def create
remote_ip_address = request.headers['X-Forwarded-For'] || request.headers['REMOTE_ADDR']
@session = Session.create(params[:username], params[:password], remote_ip_address)
if @session.valid?
session[:current_user] = @session.user
redirect_to root_url
else
flash.now[:error] = 'Unable to authenticate. Please try again'
render :new
end
end
def destroy
session[:current_user] = nil
redirect_to session
end
end
These three actions provide login and logout.
new
displays the login form with username & password fieldscreate
uses the username and password from the form as well as the ip address to create a session (i.e. authenticate). In case the request hops through some proxy servers we use the X-Forwarded-For header to get the source IP and not the proxy’s IP.destroy
users need to log out (but we wont talk about that anymore here)
This works, but you shouldn’t trust me. We need tests around the create
action!
Unit Testing the IP Whitelist with RSpec
Our Controller Spec needs to pass all 3 pieces of information (username, password & ip address) to the controller. Passing the username and password is pretty standard and something I’m sure you’ve done before. They come from a form so we pass them as a hash in the second argument to post.
post :create, {:username => 'alex', :password => 'secret'}
Unfortunately we can’t pass the IP the same way because the post method in ActionController::TestCase doesn’t support passing headers in (but it does take the session or flash - that’s interesting to remember for some other time).
def post(action, parameters = nil, session = nil, flash = nil)
process(action, parameters, session, flash, "POST")
end
If we keep looking around it turns out the ActionDispatch::TestRequest object has a nice convenience method that lets us specify the remote_addr directly.
def remote_addr=(addr)
@env['REMOTE_ADDR'] = addr
end
If we add a line to our spec we can handle the case where the IP comes in the REMOTE_ADDR HTTP header.
request.remote_addr = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
We still need to deal with the X-Forwarded-For case. While Rails doesn’t give us a convenience method, by looking at the implementation of the remote_addr= method we can see how to set this header ourselves.
request.env['X-Forwarded-For'] = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
Putting it all together we end up with a controller spec that looks like this.
require 'spec_helper'
describe SessionsController do
describe '#create' do
describe 'successfully' do
let(:alex) { mock }
let(:valid_session) { mock(:valid? => true, :user => alex )}
before do
Session.should_receive(:create).with('alex', 'secret', '192.168.1.100').and_return(valid_session)
end
describe 'using REMOTE_ADDR' do
before do
request.remote_addr = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
end
it { should redirect_to root_path }
it { should set_session(:current_user).to(alex)}
end
describe 'using X-Forwarded-For' do
before do
request.remote_addr = '172.16.254.1'
request.env['X-Forwarded-For'] = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
end
it { should redirect_to root_path }
it { should set_session(:current_user).to(alex)}
end
end
describe 'unsuccessfully' do
let(:invalid_session) { mock(:valid? => false) }
before do
Session.should_receive(:create).with('alex', 'secret', '192.168.1.100').and_return(invalid_session)
end
describe 'using REMOTE_ADDR' do
before do
request.remote_addr = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
end
it { should render_template :new }
end
describe 'using X-Forwarded-For' do
before do
request.remote_addr = '172.16.254.1'
request.env['X-Forwarded-For'] = '192.168.1.100'
post :create, {:username => 'alex', :password => 'secret'}
end
it { should render_template :new }
end
end
end
end
To sum up we can
-
pass parameters as a hash in the post method
post :create, {:username => 'alex', :password => 'secret'}
-
set the remote_addr on the request with a convenience method
request.remote_addr = '192.168.1.100'
-
et the X-Forwarded-For directly on the requests’s environment hash
request.env['X-Forwarded-For'] = '192.168.1.100'
Integration Testing the IP Whitelist in a Cucumber Feature
We face a similar issue when writing our cucumber scenarios - its easy to pass the username and password but harder to pass the IP address. The solution turns out to be similar but not quite exactly the because our Cucumber steps will use Capybara instead of ActionController::TestCase directly. Before we look into how to implement the steps, let’s write the feature we want which will help us define the steps we need.
Feature: Authentication of a user
In order to ensure a really secure application
As a user
I want my IP address to be validated during login
Background:
Given the following user exists:
| username | password | company |
| alex | secret | ip_address: 192.168.1.100 |
Scenario: Successful log in
Given I am connecting from ip "192.168.1.100"
When I log in as "alex" with password "secret"
Then I should be on the home page
Scenario: Successful log in with X-Forwarded-For header
Given I am connecting from ip "192.168.1.100" behind a proxy
When I log in as "alex" with password "secret"
Then I should be on the home page
Scenario: Failed log in from wrong IP
Given I am connecting from ip "172.16.254.1"
When I log in as "alex" with password "secret"
Then authentication should have failed
Scenario: Failed log in from wrong IP behind a proxy
Given I am connecting from ip "172.16.254.1" behind a proxy
When I log in as "alex" with password "secret"
Then authentication should have failed
We immediately realize we don’t know how to write the first step
Given /^I am connecting from ip "([^"]*)"$/ do |ip_address|
pending # How do we set the IP Address???
end
To figure this out we need to dig into how capybara works.
We don’t call post
in ActionController::TestCase directly instead letting capybara do it for us.
To see what capybara is doing we can skip that step and implement the login step
Given /^I am connecting from ip "([^"]*)"$/ do |ip_address|
# do nothing for now
end
When /^I log in as "([^"]*)" with password "([^"]*)"$/ do |name, password|
visit(new_session_path)
fill_in('User name', :with => name)
fill_in('Password', :with => password)
click_button('Log In')
end
and edit the SessionsController to show us the stack trace.
class SessionsController < ApplicationController
def create
raise caller.inspect
end
end
The stack trace is very big but if we look closely, somewhere in the middle of it we see lines below that show how capybara uses the rack-test gem to submit our form.
~/.rvm/gems/ruby-1.8.7-p334/gems/rack-test-0.6.1/lib/rack/test.rb:66:in `post'
~/.rvm/gems/ruby-1.8.7-p334/gems/capybara-1.1.2/lib/capybara/rack_test/browser.rb:62:in `send'
~/.rvm/gems/ruby-1.8.7-p334/gems/capybara-1.1.2/lib/capybara/rack_test/browser.rb:62:in `process'
~/.rvm/gems/ruby-1.8.7-p334/gems/capybara-1.1.2/lib/capybara/rack_test/browser.rb:27:in `submit'
~/.rvm/gems/ruby-1.8.7-p334/gems/capybara-1.1.2/lib/capybara/rack_test/form.rb:64:in `submit'
... more lines omitted...
~/.rvm/gems/ruby-1.8.7-p334/gems/capybara-1.1.2/lib/capybara/node/actions.rb:38:in `click_button'
Looking at the Rack::Test#post method we see something similar to what we saw before in ActionController::TestCase but its not quite identical. It takes the env as a parameter so we need to figure out how to inject our header in there.
def post(uri, params = {}, env = {}, &block)
env = env_for(uri, env.merge(:method => "POST", :params => params))
process_request(uri, env, &block)
end
Following the stack trace up we see the env
passed into Rack::Test::Session.post
comes from Capybara::RackTest::Browser
and it turns out that env is
computed in the Capybara::RackTest::Browser#env method.
def options
driver.options
end
def env
env = {}
begin
env["HTTP_REFERER"] = last_request.url
rescue Rack::Test::Error
# no request yet
end
env.merge!(options[:headers]) if options[:headers]
env
end
The key is in the line env.merge!(options[:headers]) if options[:headers]
and those options are delegated to the driver.
Now we know how to inject our IP address onto the driver’s options.
Given /^I am connecting from ip "([^"]*)"$/ do |ip_address|
page.driver.options[:headers] = {'REMOTE_ADDR' => ip_address}
end
Putting it all together we can write all our steps
Given /^I am connecting from ip "([^"]*)"$/ do |ip_address|
page.driver.options[:headers] = {'REMOTE_ADDR' => ip_address}
end
Given /^I am connecting from ip "([^"]*)" behind a proxy$/ do |ip_address|
page.driver.options[:headers] = {'X-Forwarded-For' => ip_address}
end
When /^I log in as "([^"]*)" with password "([^"]*)"$/ do |name, password|
visit(new_session_path)
fill_in('User name', :with => name)
fill_in('Password', :with => password)
click_button('Log In')
end
Then /^I should be on the home page$/ do
URI.parse(current_url).path.should == root_path
end
Then /^authentication should have failed$/ do
page.text.should include 'Unable to authenticate. Please try again'
end
Now the scenarios we wrote before all pass.
To sum up
-
capybara handles form submission superbly with
fill_in('User name', :with => name)
click_button('Log In')
-
we can set any HTTP header in capybara with
page.driver.options[:headers] = {'REMOTE_ADDR' => ip_address}
Testing is good
Since we’re testing the IP logic at both the unit level with RSpec and integration level with Cucumber and Capybara we can be pretty sure it’s all going to work correctly.