SSL in Ruby on Rails

Update

The world has shifted since I wrote this solution and blog post. The popularization of Firesheep and concerted efforts in understanding and optimizing SSL performance has led to the wise trend of enforcing HTTPS everywhere. There are much better (and simpler) ways to secure Rails applications. The one aspect of my solution that I still recommend incorporating is logging non-GET or HEAD requests so you can identify any forms that are misbehaving. Reader beware…


I was supposed to simply verify that everything in a Rails 2 application was properly using SSL to serve pages and receive requests over HTTPS. The web server had already been configured with the appropriate SSL certs, and developers had been using different constructs in Rails to force certain actions to be protected. There was no reason to believe things were broken or spewing secrets; I just needed to dot the I’s and cross the T’s. Specifically, I needed to verify that:

  1. Pages that needed the familiar SSL security lock icon showed it in the browser.
  2. That any such pages’ forms actually POSTed over HTTPS.
  3. That the margin for developer coding error was slim.
  4. And that we did all these without hurting developers’ brains. This meant ensuring things were DRY and as simple to use as possible.

Being my first experience validating usage of SSL in a Rails application, I set out to read-up on the topic. To my surprise, I discovered that there are multiple ways of using SSL in Rails. To make matters worse, the application I was verifying used all three of them equally, none with conviction, and this mix gave us a false sense of assurance that everything was correct and safe, when in fact it wasn’t. The core of how we utilized SSL in Rails needed to be re-written.

The Options

The popular ways of requiring SSL in Rails are:

  1. Passing in :protocol => 'https' in calls to *_url helpers when generating links and forms that should be GET or POSTed over HTTPS.
  2. Specifying :requirements => { :protocol => 'https' } in specific routes in routes.rb to accomplish the same thing, but in a central place.
  3. Using DHH’s ssl_requirement plugin.

(I suggest reading the pages at the links above before continuing. SSL in Rails isn’t cake and understanding each will help you follow the rest of my article.) Using option 1 exclusively will quickly fill your views and helpers with extraneous code and your ERB output with absolute URLs, which will pack more bytes in your pages than relative ones. You will also have to make sure you’re specifying HTTPS in each URL helper call for that URL, which is not DRY. However, that’s not the worst of it: it will do nothing on the controller-side to ensure communication between browser and server actually took place over HTTPS.

    <% form_tag(:method => 'post', create_user_url(:protocol => 'https')) do %>

    def create
      # I'll take HTTPS. Heck, I'll take HTTP too!
    end

It’s way too easy to create an HTTP URL to the same action without even thinking.

    <% form_tag(:method => 'post', create_user_url) do %>

Using option 2 will take care of centralized HTTPS requirements for helpers, but it will make your routes messy because you have to require SSL for an entire resource or break out actions on resources that require SSL into their own route lines.

    map.resources :users, :only => [:new, :create], :requirements => { :protocol => 'https' }
    map.resources :users, :only => [:show]

This gets particularly messy when you have nested resources. This also does nothing on the controller-side to ensure SSL actually took place. It only helps in generating SSL paths and URLs like option 1. Option 3 is the best because it not only ensures SSL actually took place on the controller-side, it also allows you to more-DRYly define SSL requirements in one-place: the controller because of redirects.

    class UsersController < ApplicationController
      ssl_required :new, :create
      ...
    end

A before_filter ensures the proper protocol is used for an action. If the right protocol is used, the action executes as expected. If the wrong protocol is used, Rails tells the browser to try the request again over the proper protocol. This makes your life a lot easier and better by requiring less code and producing thinner ERB output. How? First, you can completely ignore specifying HTTPS in helpers to URLs for pages that need to have the security lock icon. When fetched with HTTP, the server will tell the browser to request it again, but this time over HTTPS. Second, all of your URLs can be generated by *_path helpers instead of *_url helpers. If the origin page is HTTP, the relative URL will be HTTP. If the origin page is HTTPS, the relative URL will be HTTPS. In either case, ssl_requirement will ensure the right protocol is used.

    def UsersController < ApplicationController
      ssl_required :new, :create
      ...
    end

    def new
      # I require HTTPS. If you try to GET to me over HTTP, ssl_requirement will redirect you to the HTTPS URL of this request.
    end

    <% form_tag(:method => 'post', create_user_url) do %><%# This will produce a relative path, which will resolve to HTTPS, because :new was fetched or redirected to HTTPS. %>

    def create
      # I require HTTPS. If you hack your browser and try to POST to me over HTTP, ssl_requirement will redirect you to POST again to the HTTPS URL.
    end

So, specifying both the origin and the target pages in your calls to ssl_required, and ssl_requirement will take care of you.

My Mods

Now, as you can see, ssl_requirement works the same for form requests too. However, do you really want a form POSTed over HTTP to redirect to HTTPS? Most browsers will warn a user that they are about to make a request from a secure page to an insecure URL. And yet you’ll never know this is happened unless you are watching your Rails logs like a hawk. Even if you did, do you really want your application to let that happen? It makes for a very bad user experience. On top of that, even though the browser will try again over SSL, any sensitive data will have already been transmitted insecurely over HTTP. By that point, who cares if the data is resent securely? The secret has already been exposed! You never want your application to let a user do this. To ensure your code never has a bug that allows for this to happen, you can tweak ssl_requirement by overriding ensure_proper_protocol such that it raises an exception if anyone performs on action over HTTP that requires HTTPS. (I’m assuming you’re watching your application exceptions. You should be.) If someone does try to request an HTTPS action over HTTP, and exception is thrown, and now you know you missed something.

    class ApplicationController < ActionController::Base
      ...
      protected
        def ensure_proper_protocol
          if !ssl_allowed? && ssl_required? && !request.ssl? && !(request.get? || request.head?)
            raise 'SSL required!' # either we have a bug somewhere or someone is playing with us.
          else
            super
          end
        end
    end

You don’t throw an exception for GETs or HEADs because you rely on ssl_requirement to redirect them to HTTPS so you can DRYly define SSL requirements in controllers only. If exceptions gets out-of-control in your production environment because someone is playing with you or has a misbehaving browser, you can always switch to logging the issue and redirect the user to a 404 page or your home page. Just watch your logs! Now, you might be even lazier, like me, and not have SSL setup on your laptop for development. To get around setting it up, you can also override ssl_required? for the development and local test environments so that ssl_requirement doesn’t function. Although, you probably want it in integration tests, because integration tests can simulate SSL. To use it there, you can use an ENV variable and temporarily toggle it in setup and teardown so that ssl_requirement functions.

    environment.rb
      ENV['SSL_PROTOCOL'] = 'https'

    development.rb
      ENV['SSL_PROTOCOL'] = 'http'

    test.rb
      ENV['SSL_PROTOCOL'] = 'http'

    class ApplicationController < ActionController::Base
      ...
      private
        def ssl_required?
          return false if ENV['SSL_PROTOCOL'] == 'http'
          super
        end
    end

    an_integration_test_that_wants_to_test_the_new_and_improved_ensure_proper_protocol.rb
      def setup
        ENV['SSL_PROTOCOL'] = 'https'
      end

      def teardown
        ENV['SSL_PROTOCOL'] = 'http'
      end

      def test_that_exception_is_raised
        post '/users', {:user => {:name => 'Natalia Rose'}}
        assert_response :error, 'SSL required!'
      end

Caveats

Query Strings

Okay, I’m assuming here that you don’t want to put secure data in a GET or HEAD request query string. You can’t use the DRY technique of omitting HTTPS in your helper calls and only putting them in your controllers if you need to encrypt query strings. The reason I make this assumption is because it’s not the best idea. The string will show up in all sorts of server logs unencrypted without some extra configuration. Even if you do configure them, it will still show up in users’ browser histories too. The better option is to use POSTs to send params instead of GETs.

Explicit HTTP to HTTPS POSTS

In a few instances, you might need to POST from an insecure page to a secure one. (This should be rare.) If you do, you can use option 1 (use a *_url helper and specify the :protocol => 'https'.)

Give me the Code!

Okay, so here’s everything in one place:

  environment.rb
    ENV['SSL_PROTOCOL'] = 'https'

  development.rb
    ENV['SSL_PROTOCOL'] = 'http'

  test.rb
    ENV['SSL_PROTOCOL'] = 'http'

  application_controller.rb
    protected
      def ensure_proper_protocol
        if !ssl_allowed? && ssl_required? && !request.ssl? && !(request.get? || request.head?)
          raise 'SSL required!' # either we have a bug somewhere or someone is playing with us.
        else
          super
        end
      end

    private
      def ssl_required?
        return false if ENV['SSL_PROTOCOL'] == 'http'
        super
      end

  users_controller.rb
    ssl_required :new, :create, :edit, :update

  a_form_on_an_insecure_page_that_prefills_users_new_which_is_a_secure_page.html.erb
    <% form_tag(:method => 'get', new_user_url(:protocol => ENV['SSL_PROTOCOL'])) do %>

  an_integration_test_that_wants_to_test_the_new_and_improved_ensure_proper_protocol.rb
    def setup
      ENV['SSL_PROTOCOL'] = 'https'
    end

    def teardown
      ENV['SSL_PROTOCOL'] = 'http'
    end

    def test_that_exception_is_raised
      post '/users', {:user => {:name => 'Natalia Rose'}}
      assert_response :error, 'SSL required!'
    end

  a_integration_test_that_uses_shoulda.rb
    context 'some request' do
      setup do
        ENV['SSL_PROTOCOL'] = 'https'

        get '/users'
      end

      should_require_ssl

      teardown do
        ENV['SSL_PROTOCOL'] = 'http'
      end
    end

  shoulda_extensions.rb
    # Functional Testing
    def should_require_ssl_for(*actions)
      should 'require ssl for' do
        assert_equal actions, controller.class.read_inheritable_attribute(:ssl_required_actions)
      end
    end

    def should_not_require_ssl
      should 'not require ssl' do
        assert_equal nil, controller.class.read_inheritable_attribute(:ssl_required_actions)
      end
    end

    def should_allow_ssl_for(*actions)
      should 'allow ssl for' do
        actions = [:controller] + actions unless actions.include?(:controller)
        assert_equal actions, controller.class.read_inheritable_attribute(:ssl_allowed_actions)
      end
    end

    def should_not_allow_ssl
      should 'not allow ssl' do
        assert_equal [:controller], controller.class.read_inheritable_attribute(:ssl_allowed_actions)
      end
    end

    # Integration Testing
    def should_require_ssl
      should 'require SSL' do
        assert https?
      end
    end

As I mentioned, SSL in Rails isn’t cake, so there is a very, very good chance I’ve got something wrong in this article, or even in my solution. If you spot something, please take a minute to voice your concern, question, or finding. We can all benefit from this. Thanks!

13 thoughts on “SSL in Ruby on Rails

  1. It’s been a while since you posted this, but even now it somehow helped me understanding things better. Thanks!

  2. I have used the SSL requirement plugin for requiring the ssl for some methods in my application. Now, I have added teh SSL certificate to my rails application.

    But when coming to these methods(ie., ssl required) there are shown in red lock symbol rather than showing in green lock.

    Can you please suggest me why?

    Thanks,
    Sai.

    1. There are quite a few reasons why it could be red. I recommend figuring out what the browser is saying about the certificate itself. The Rails pieces just ensure that the browser is trying to use the right protocol (HTTPS or HTTP). You will probably need to reference the browser documentation, its error message, and maybe even documentation from the certificate issuer or your web server.

      That said, my guess is that either you are trying to use a self-signed certificate that your browser doesn’t trust, or you bought a non-wildcard certificate for something like yourwebsite.com and you’re trying to view the page at http://www.yourwebsite.com

      Hope this helps.

  3. For best security you should try and use SSL for all your URLs in an application. Even if it is just serving up an index page. Here is why. Lets say your user, Alice, logged in via standard devise routes (https://bob.example.com/users/sign_in) and this was over https so everything was secured (user_id, password and the rails session). But somewhere in your app you had a link going back to your root/index and lets say you decided that it did not need SSL. Now if the user visits this page over http, all your cookies are also sent over unsecured traffic including your session_id. If the user was in starbucks or using some public wifi, she just told anyone listening what her session id is. Now hacker, Eve, knows her session id and see can run get requests against all your resources using that session id. In certain situations she can also change data by faking requests coming from legitimate session.

    So save yourself trouble and put
    config.force_ssl = true
    in your production.rb

    1. Thanks for looking-out for people, Pankaj.

      Running one’s entire web site over SSL is not an option for everybody–especially those that have any sort of notable traffic. The biggest reason is that SSL will either increase load on one’s infrastructure or it will cost him a lot of money to ensure things are still speedy because of the added load. The problem with sites that are exploitable the way you described is that they allow session cookies to leak out over pages that are not session-specific. If you keep session-specific pages behind HTTPS and mark your session cookies as secure, you’re still safe.

      1. Ian

        Thanks for your comment back. I understand your comment about sites that have portions with high traffic and do not need security and how it would add to infrastructure cost. You must decide how much is the cost and what is the cost of security exploit on your site. Some SSL on cloud can be fairly cheap (now running less then 10% of your machine hour usage and goes down as you add more instances behind the SSL proxy router).

        Your point about marking cookies as secure is right on. If you are deploying applications in production you should definitely do this. Thanks for pointing out. If you are using the devise gem, by default it will *not* mark the cookies as secure so you should definitely do so.

        Overall great article! You should cover a little about config.force_ssl and config.ssl_options ({:exclude => xxx}) as that I believe is often overlooked.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.