Security tips for rails apps

August 27, 2018 | Adrien Siami | 6-minute read

As your application gets larger and larger, the surface area for security issues expands accordingly, and security bugs become more and more problematic.

Here are a few tips to avoid some common pitfalls regarding security for Rails apps.

Use I18n with html tags properly

It is quite common to want to mix I18n translation keys with HTML tags. I’d recommend against doing that as much as possible, but sometimes you can’t really avoid it.

Let’s take the following example:

# en.yml
en:
  hello: "Welcome <strong>%{user_name}</strong>!"
<%= t('hello', user_name: current_user.first_name) %>

We have a problem here, because this will produce the following output:

Welcome <strong>John</strong>!

Oops! Indeed, our string was never marked as html safe, therefore rails will escape html entities.

One (bad) way to fix it would be to do the following:

<% # Don't do this! %>
<%= t('hello', user_name: current_user.first_name).html_safe %>

While it works, we just exposed ourselves to a nasty XSS. Indeed, our user can now change their name with some pesky JavaScript in it and the JavaScript will be executed.

XSSes are often underrated as benign security issues, but they can be fatal if exploited properly.

Fortunately, Rails has a nice solution for us: if an I18n key ends up with _html, it will automatically be marked as html safe while the key interpolations will be escaped!

# en.yml
en:
  hello_html: "Welcome <strong>%{user_name}</strong>!"
<% # Do this! %>
<%= t('hello_html', user_name: current_user.first_name) %>

🎉

Note that this is pretty much the same as doing this:

<% # Don't do this either! %>
<%= t('hello', user_name: h(current_user.first_name)).html_safe %>

One good way to avoid XSSes is to really try to avoid using html_safe (or raw) as much as possible, and when forced, double check that you have full control of the content displayed.

Be defensive by default

You can’t trust user params; you most likely already know that. But there are different ways to implement sanitization of user params.

Let’s pretend we have a form, and we want to use one of two different Form Objects depending on a param:

class FooForm; end
class BarForm; end

form_klass = "#{params[:kind].camelize}Form".constantize # Don't do that
form_klass.new.submit(params)

Here, we get the good form class by constantizing a string that is controllable by the user. This is very bad practice and can lead to terrible side effects (imagine sending make_user_admin instead of foo or bar)

One solution could be to do this:

if params[:kind] == 'foo' || params[:kind] == 'bar'
  form_klass = "#{params[:kind].camelize}Form".constantize # Still, don't do that
  form_klass.new.submit(params)
end

Here we are ‘safe’. We check that the params are one of the two expected values and only constantize if needed. While this works fine, we haven’t corrected the root security issue (which is the use of constantize over user input).

Code grows old and evolves, developers copy and paste parts constantly, and at some point your offending line could end up outside of its guard.

Now let’s have a look at this alternative:

klasses = {
  'foo' => FooForm,
  'bar' => BarForm
}

klass = klasses[params[:kind]]
if klass
  klass.new.submit(params)
end

We have the same behaviour as above, except this time we don’t use constantize.

By being defensive and keeping a close eye on user input, we can avoid many basic security issues.

Beware of arrays or hashes

Consider the following code:

  # POST /delete_user?id=xxx

  def can_delete?(user_id)
    other_user = User.find_by(id: user_id)
    current_user.can_delete?(other_user)
  end

  user_id = params[:id]

  if can_delete?(user_id)
    User.where(id: user_id).update(deleted: true)
  end

This code is voluntarily weird-looking in its structure to be vulnerable to the security issue, but trust me, I’ve seen it in the wild ;)

Everything works ok here until we start messing a bit with the params.

Let’s imagine we send the following request:

POST /delete_user?id[]=42&id[]=43&id[]=44&id[]=45..

Rails will parse params[:id] as an array: [42, 43, 44, 45]

  User.find_by(id: [42, 43, 44, 45]) # This will return one user with id 42 (lower id)

  User.where(id: [42, 43, 44, 45]).update(deleted: true) # This will update all of those records!

Thanks to some weirdness in find_by and messing with the params, here we managed to act on records we may not have access to.

It’s always good to remember that params can also be arrays (or hashes!) as that can pose some security risks.

Remember that evil input is not always where we think it is

Most of us are very wary when dealing with user params, or values coming from the database.

However, there are some attack vectors that can be forgotten, including (but not limited to) the following:

  • Cookies: they are 100% editable by the user
  • Other headers in general: Referer, User-Agent, etc.
  • User IP: easily spoofable on misconfigured apps (using X-Forwarded-For)
  • Local files: here’s an interesting example of poisoning the ssh auth.log file in order to perform a remote code execution.

It’s always good to think about where any given input comes from and wonder if it can be tampered.

👍  Like this post? Join Drivy's engineering team! View openings