Combining two related fields in a url to prevent tampering
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.