Rails Cross-Origin Resource Sharing

The Ruby way of solving this problem. By Yihang Ho

With the rise of several client-side frameworks like AngularJS, servers are increasingly used as API endpoints. If you are using Rails and trying to port your front-end over to one of those client-side frameworks, the first thing that your browser developer console will tell you is something like this:

No 'Access-Control-Allow-Origin' header is present on the requested resource.

What does this mean and how to solve this problem?

Cross-Origin Resource Sharing

This message implies that your server does not want to share its resources with people coming from other origins. For example, if you are running http://www.example.com, your server is not happy to share its resources with people from http://nope.example. This is called Cross-Origin Resource Sharing (CORS) policy, and it can be configured by tweaking the values of some of the HTTP response headers. The most important one is Access-Control-Allow-Origin which should be set to the origin which your server wants to share its resources with.

Solutions given by the interwebs

Other solutions that I have found on the Internet assumes that CORS is allowed every single action in your Rails app. My solution, however, does not make this assumption, and CORS can be switched on and off for any action as you like. The final product is available here on GitHub. Please note that this sample app supports only two actions:

GET /posts
DELETE /posts/X

If you need an example client, here is one.

Example

For this example, assume that our Rails server is serving at http://localhost:3000, and our front-end client is accessible at http://localhost:8000. Although only the ports are different, in the context of HTTP, they belongs to two different origins.

Solution for simple HTTP methods

If our APIs involve only simple HTTP methods (GET, HEAD and POST), all we need to do is to set the value of Access-Control-Allow-Origin. This field can take the value of Origin request header, * or null.

We can achieve this by implementing a thin wrapper, allow_cors, to set up before_action callback and properly disable Rails CSRF protection for those actions.

class ApplicationController < ActionController::Base
  # allow_cors takes in arbitrarily many symbols representing actions that
  # CORS should be enabled for
  def self.allow_cors(*methods)
    before_filter :cors_before_filter, :only => methods

    # Rails recommends to use :null_session for APIs
    protect_from_forgery with: :null_session, :only => methods
  end

  def cors_before_filter
    # Check that the `Origin` field matches our front-end client host
    if /\Ahttps?:\/\/localhost:8000\z/ =~ request.headers['Origin']
      headers['Access-Control-Allow-Origin'] = request.headers['Origin']
    end
  end
end
class PostsController < ApplicationController
  allow_cors :index, :other_methods

  def index
    # ...
  end

  def other_methods
    # ...
  end
end

Not-so-simple HTTP methods

What if your application is using HTTP requests such as DELETE or PATCH? Well, your browser will first send a preflight request, which is used to determine if your app is allowed to send the actual request. This preflight request is actually an OPTIONS request with some important request headers set:

  • Access-Control-Request-Method: The HTTP method that it will use in the actual request.
  • Access-Control-Request-Headers: The list of headers that the client want to send to the server.
  • Origin: The origin of this request.

As an example, suppose your front-end client wishes to make this HTTP request:

DELETE http://localhost:3000/users/1.json

Lucky-Number: 123
No-So-Lucky-Number: 456

Before the browser executes this request, it will first send a preflight request that looks like this:

OPTIONS http://localhost:3000/users/1.json

Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Lucky-Number, Not-So-Lucky-Number
Origin: http://localhost:8000

And your server will send a response and inform the browser its decision using headers:

  • Access-Control-Allow-Origin - The friendly origin. As mentioned earlier, this can take the value of the Origin request header, * or null.
  • Access-Control-Allow-Methods - The list of HTTP methods that it allows. Note that this value is case sensitive, and the correct form is all caps, like GET, not Get.
  • Access-Control-Max-Age - How long (in seconds) will this response remains valid. You can set this to a large number like 1728000 (which is 20 days) unless your CORS policy is rather erratic.
  • Access-Control-Allow-Headers - The list of additional headers that it allows.

The browser will execute the actual request if and only if

  • Access-Control-Allow-Origin matches the value of Origin request header or *.
  • Access-Control-Allow-Methods contains Access-Control-Request-Method.
  • Access-Control-Allow-Headers is a superset of Access-Control-Request-Headers.

So to handle the preflight requests, our Rails app will have to be able to construct a proper response to it, and this starts with responding to OPTIONS request. First, we will generate a controller to actually respond to these preflight requests:

rails generate controller Cors preflight

Then make it respond to all the preflight requests. In routes.rb,

# config/routes.rb
Rails.application.routes.draw do
  # Remove get 'cors/preflight' added by the generator
  ...
  ...
  ...
  # At the end of all your existing configurations, add this line:
  match '*path' => 'cors#preflight', :via => :options
  # Essentially it maps all OPTIONS request to our newly generated controller
end

Just like what we have done with the simple methods requests, we will only allow CORS for actions that are explicitly listed. Hence, we have to record the list of allowed actions:

class ApplicationController < ActionController::Base
  def self.cors_allowed_actions
    @cors_allowed_actions ||= []
  end

  def self.cors_allowed_actions=(arr)
    @cors_allowed_actions = arr
  end

  def self.allow_cors(*methods)
    self.cors_allowed_actions += methods
    before_filter :cors_before_filter, :only => methods
    protect_from_forgery with: :null_session, :only => methods
  end
end

Here comes the more complicated part. First, we ask Rails which controller and action are responsible for the request, then check if that action is allowed to enable CORS.

class CorsController < ApplicationController
  def preflight
    begin
      http_request_verb = request.headers['Access-Control-Request-Method']
      # You should add/remove HTTP methods that you want/do not want to support
      raise unless ["PUT", "PATCH", "DELETE"].include? http_request_verb

      # This line will raise an exception if the path does not resolve to any controller/action.
      details = Rails.application.routes.recognize_path(request.original_fullpath, :method => http_request_verb.downcase.to_sym)

      # details looks something like this { :controller => "posts", :action => "index" }

      # Convert to the controller class name (posts => PostsController)
      controller_class_name = details[:controller].capitalize + "Controller"
      # Since we recorded the action names as symbol, we should convert it to symbol here
      action_name = details[:action].to_sym

      # If this statement returns true, then CORS is allowed
      if eval(controller_class_name).cors_allowed_actions.include?(action_name)
        headers['Access-Control-Allow-Origin']  = request.headers['Origin']
        headers['Access-Control-Allow-Methods'] = http_request_verb
        # Change this to something smaller while you are debugging
        headers['Access-Control-Max-Age']       = "1728000"
        # Change this to the list of accepted headers, or remove it if you do not accept any.
        headers['Access-Control-Allow-Headers'] = request.headers['Access-Control-Request-Headers']
      end
    rescue
    end

    # Render empty stuff since preflight requests care only about the headers
    render :text => "", :content_type => 'text/plain'
  end
end

And this if how you handle CORS the Ruby way.

Reference

  1. W3C CORS Specification