Combining two related fields in a url to prevent tampering

October 11, 2010

Usually when you select an item from a search results list you only need to pass the id in the url and that's enough to lookup the rest of the details when processing the response. Recently I had a case where I couldn't lookup the item from the id because it wasn't in my database. I'm going to describe how I passed several pieces of information in a single url and what I did to ensure users could not be manipulate them independently.

My application was searching and displaying a list of companies then keeping track of which one the user selects.



When someone clicks on a company name it would save the company's id and display a nice message "Thanks for selecting Google Inc." Since the companies came from a slow external service I didn't want to call again, I passed both the id and name in the url.

I built a couple of simple haml files to show my company list. Each row had a link to display the company name and passed both the name and id in the url.

# search_results.html.haml
%table
  %tr
    %th Company Name
    %th Location
  = render @companies


# _company.html.haml partial
%tr
  %td= link_to company.name, company_path(:id=>company.id, :name=>company.name)
  %td= company.location



The controller code was also pretty straightforward.

class CompaniesController
  def show
    company = Company.new(:id=>params[:id], :name => params[:name])
    current_user.update_selected_company = company
  end
end



This worked great and worked, but then we started looking at the urls in our browser and noticed they looked like http://my.site.com/companies/60902413&name=Google+Inc..

Hmm ... What would happen if someone changed the name in the url? We tried loading http://my.site.com/companies/60902413&name=Some+Silly+Name. Uh-oh, our database now stored the selected company Some Silly Name with id #60902413. This could be confusing or worse a security risk where a clever (or malicious) user could store inaccurate information in our database.

Our solution was to combine the two fields into a single query parameter that was resistant to user tampering and is passed as a single unit. Fortunately Rails passes the session back and forth in a cookie with just that requirement. The session is a hash of many different key-value pairs that need to be encoded as a single cookie, it also contains sensitive information that should be resistant to tampering (and unreadable) and, most importantly it turns out to be something we could reuse.

Starting from the outside in what we wanted to do was rewrite our _company.html.haml partial view to put the single encrypted form of the company on the url.

%tr
  %td= link_to company.name, company_path(:id=>company.to_encrypted_s)
  %td= company.location

and parse that in the controller

class CompaniesController
  def action
    company = Company.from_encrypted_s(params[:id])
    current_user.update_selected_company = company
  end
end



Ok, but what do those to_encrypted_s and from_encrypted_s methods do?

class Company
  def to_encrypted_s
    Encryption.new.encrypt(:company_name=>company_name, :duns=>duns)
  end

  def self.from_encrypted_s encrypted_data
    Company.new Encryption.new.decrypt(encrypted_data)
  end
end

I still haven't told you how they work, we're just working down from the outside-in figuring out what other classes we'll need. So what does the Encryption class look like?

class Encryption
  def initialize
    secret = (Rails.version > '3.0') ?
             Rails.application.config.cookie_secret : 
             ActionController::Base.session_options[:secret]
    @verifier = ActiveSupport::MessageVerifier.new(secret, 'SHA1')
  end

  def encrypt message
    @verifier.generate message
  end

  def decrypt encrypted
    @verifier.verify encrypted
  end
end



We make use of the ActiveSupport::MessageVerifier class which as the documentation says "makes it easy to generate and verify messages which are signed to prevent tampering".

Now when we select a company we get a url with a crazy long id like http://my.site.com/company/BAh7BzoJZHVuc2kDE%2BAUOhFjb21wYW55X25hbWUiMEludGVybmF0aW9uYWwgQnVzaW5lc3MgTWFjaGluZXMgQ29ycG9yYXRpb24%3D--9027b2c449c0b4a1aea375cb1722fa9db8e56066. If someone were to try and change the really long id in the url the application will throw an ActiveSupport::MessageVerifier::InvalidSignature exception instead of saving bad data.

We've given up readability in the url but in exchange we're guaranteed the id and name we get in the controller go with each other.