I was coding along in Rails with security in mind using the simple Assert Request plugin from Scott A Woods in my controllers and ran into a spot where I wanted to require the presence of a session variable before allowing the rest of an action to execute. The specific motivation was conceived when coding a controller for an invitation mechanism. Normally, a user invites another user to a system, the invited user comes to the system with their invite-code (probably from a link in an email), completes some form (let’s call it invite-accept), and then submits the final request (invite-update).
What I didn’t want was an easy way for a user to be able to by-pass the invite-accept and go straight to invite-update. To prevent this, I wanted the invite-code to be available to invite-update, but not via a parameter. The quickest and safest way I could think was to require that it be in the session, which would theoretically force the user to first visit invite-accept. This way, only invited people can accept invites and they must go through invite-accept.
I also really liked the convention of using assert_request at the head of each of my actions (I even implemented an assert_request_has_no_params helper for most of them) and I didn’t want to break from it to do a:
def some_action
raise Exception unless session[:invite_code]
# ...
end
Interpretation
So, I wrote an extension to AssertRequest called - oddly enough - AssertSession. It’s not complex and looks very similar to existing AssertRequest code. It currently supports two directives:
:must_have- Requires the existence of declared session variable(s) or an error is raised:may_have- Allows for the existence of declared session variable(s)
The following code example shows how one might require the existence of three session variables for the given request: :baz, :bum, and :bar
class FooController < ApplicationController
def bar
assert_session {|rule| rule.must_have :baz, :bum, :bar}
end
end
If any one of the session variables does not exist in the session at the time the request is processed, a AssertRequest::RequestError exception is raised with a meaningful error message; like:
What if you only wanted to require :baz, but you wanted to allow for :boo and :bar?
class FooController < ApplicationController
def bar
assert_session do |rule|
rule.must_have :baz
rule.may_have :boo, :bar
end
end
end
For this example, an error will only be thrown if :baz is not defined at the time of the request. :boo and :bar may or may not be defined. This was useful for me to allow session variables that might have been stored during login actions, like a return_to (though, in this case, it might be more prudent to allow a global ignore style declaration).
In addition, any variable found in the session that is not declared as a must_have or a may_have is considered malicious and a AssertRequest::RequestError exception is raised with a meaningful error message. For instance, given either of the above examples, the request will be halted and an error with the following message raised if a session variable is defined named :toothrot.
":toothrot not expected to be in session"
By default, AssertSession will ignore the variable named flash in the session.
AssertSession works right along AssertRequest, but cannot be called from within an assert_request block.
Application
Back to the original motivation; the invitations controller. After having implemented assert_session, I was able to use it in the controller like so:
class InvitesController < ApplicationController
# ...
def accept
assert_request { |r| r.params.must_have :invite_code }
@user = User.find_invited(params[:invite_code])
session[:invite_code] = params[:invite_code]
rescue ActiveRecord::RecordNotFound
bad_request('The invite code you provided is invalid.')
end
def update
assert_session { |s| s.must_have(:invite_code); s.may_have(:return_to) }
assert_post do |r|
r.params.must_have(:user) { |u| u.must_have(:name, :password,
:password_confirmation)}
end
# ...
end
end
The :return_to should really only be present if you’ve ever tried to login, but since I do a lot of playing in the interface, I would routinely hit the problem where :return_to existed and I didn’t really feel like clearing my sessions (:return_to comes with acts_as_authenticated).
assert_post is another helper I wrote which simply does an assert_request, requires the method to be a POST, and then yields. Like this:
def assert_post(&block)
assert_request do |rules|
rules.method :post
yield rules
end
end
A cooler approach would be if assert_request itself was modified to accept the required HTTP method as an argument. An example of use would be:
Liquid error: stack level too deep
Installation
First, install the AssertRequest plugin.
ruby script/plugin install svn://rubyforge.org/var/svn/validaterequest/plugins/assert_request
Second, create a file using the code below in your Rails application and require it in your environment.rb file. When Scott Woods accepts the patch, you can simply install/update the plugin you have installed.
Code
Until Scott Woods adds the code into the plugin, the code only exists here.
module Glomp #:nodoc:
module AssertRequest #:nodoc:
module PublicMethods #:nodoc:
def assert_session
rules = SessionRules.new
yield rules
rules.validate(session)
end
end # PublicMethods
class SessionRules
def initialize
@required = []
@ignore_vars = ['flash']
end
def must_have(*args)
@required.concat(args).flatten!
self
end
def may_have(*args)
@ignore_vars.concat(args).flatten!
self
end
def validate(session)
cats = {:expected => [], :unexpected => [], :matched => []}
session.data.each do |k,v|
cat = @required.index(k) ? (!v ? :expected : :matched) : :unexpected
cats[cat] << k
end
cats[:expected] += @required - cats[:matched]
if !cats[:expected].empty?
raise ::AssertRequest::RequestError,
"#{cats[:expected].to_sentence} expected to be in session"
elsif !(cats[:unexpected] -= @ignore_vars).empty?
raise ::AssertRequest::RequestError,
"#{cats[:unexpected].to_sentence} not expected to be in session"
end
end
end
end # AssertRequest
end # Glomp
ActionController::Base.send(:include, Glomp::AssertRequest::PublicMethods)
Tests are available upon request.