IP filtering on Heroku for Ruby apps

Heroku has been a househould name in PAAS market for quite a while, in fact they've pioneered some of the most well known software delivery patterns/methodologies, aka 12-factor, which are still very popular amongs Rails developers to this day (+ many other language ecosystems). We've got a few more players on the market these days, however Heroku is still a great platform to develop, grow and scale your business upon, just comes with a price.

Folks might be familiar with platforms like AWS/GCP/whatever cloud, most of the resources deployed on such platforms are usually backed by a VPC, i.e. you get your own networking layer and can control how all systems can interact with each other and with general internet traffic. But on Heroku every application is treated exactly the same (unless you go with Private Spaces), so in layman's terms you can call Heroku a giant super computer, and you get a tiny slice of it.

Now, given that all apps on the platform run side-by-side, technically anyone can discover your application and make malicious calls, so in some cases you might want to specifically grant access from a known set of sources (static IP addresses or ranges, CIDRs), and block requests from everyone else on the internet. Luckily, we've got a few implementation options to make that happen.

Nginx Buildpack

Generally, there's no need to run Nginx on Heroku, however, it might come very handy for use in more nuanced setups (concurrency, static file sharing, ACLs, etc). Restricting IP access is one of the features we'll need.

To set this up we're adding add the buildpack to our app:

heroku buildpacks:set https://github.com/heroku/heroku-buildpack-ruby.git
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-nginx.git

With nginx server we're introducing an extra hop in the request lifecycle, though while it's running in the same "dyno" it should minimize the impact and sometimes improve the overall performance of the app.

To get started, create a new file config/nginx.conf.erb in your existing application's directory. Grab the config example.

Now, in order to add IP address filtering, modify the config as follows:

http {
  # ... bunch of contents

  server {
    listen <%= ENV["PORT"] %>;
    server_name _;
    keepalive_timeout 5;

    location / {
      <% if sources = ENV["APP_ALLOW_IP_SOURCES"] %>
        # reject all calls by default
        set $allow false;

        # allow specific sources
        <% sources.split(",").each do |src| %>
          if ($http_x_forwarded_for ~* <%= src %>) {
            set $allow true;
          }
        <% end %>

        if ($allow = false) {
          return 403;
        }
      <% end %>

      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_redirect off;
      proxy_pass http://app_server;
    }
  }
}

That should be enough to get rolling, however, the buildpack and nginx is expecting you to modify your application to start server over unix websocket instead of a standard TCP socket your Rails/Rack app would normally bind to.

You'll also need to modify your Procfile to wrap the application startup command with nginx one. Note, we're using Puma server, Unicorn would be similar:

web: bin/start-nginx bundle exec puma -b unix:///tmp/nginx.socket

Next, add this line to your config.ru file to indicate the app is ready to accept requests:

# ... snip
FileUtils.touch("/tmp/app-initialized")
# ... snip

run Rails.application

# Or if using sinatra application
# run Sinatra::Application

Pros

  • Enforce access control on proxy level, leaving application mostly unchanged.
  • Nginx configuration documentation is great and offers many examples.

Cons

  • Might be intimidating to folks unfamiliar with nginx, and hard to troubleshoot.
  • Application must be reconfigured to serve requests over unix sockets.
  • Nginx buildpack might not be supported in older stacks.
  • A massive overkill!

So far it's the least preferred method.

Rack Middleware

While the previous nginx-based approach will work for most of the applications, regarless of the language, it's worth exploring other options. In our case we've got a Rack-based app, so adding a new middleware is a breeze. Middlewares are a bunch of Ruby code in the request chain that can filter or modify any requests your application is serving.

Let's create a new file lib/ip_filter_middleware.rb:

class IpFilterMiddleware
  def initialize(app, allowed:)
    @app = app
    @allowed = allowed.map { |k| [k, true] }.to_h
  end

  def call(env)
    remote_addr = env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"]

    # Naive way of filtering by individual IP addresses.
    # Ruby's network library could be used to perform lookup by CIDR if needed.
    if remote_addr && !@allowed[remote_addr]
      puts "Remote address #{remote_addr} is not allowed!"
      return [403, {}, ["Forbidden"]]
    end

    @app.call(env)
  end
end

Add the following snippet into your config.ru file:

require_relative "lib/ip_filter_middleware"

if sources = ENV["APP_ALLOW_IP_SOURCES"]
  use IpFilterMiddleware, allowed: sources.split(",")
end

# Rest of the application initialization
run Rails.application

# Or if using sinatra application
# run Sinatra::Application

Pros

  • Super simple implementation
  • No extra dependencies

Cons

  • Will require additional testing

Rack Attack Gem

Rolling your own rack middleware is probably only useful for experimentation or educational reasons, and if you're determined enought you should still add tests for it and make sure it's functioning correctly. With that being said, there's always a gem for everything.

Enter rack-attack - a Rack based middleware layer. This gem packs tons of features, ranging from ip address filtering, all the way to request throttling. It's highly recommended for general abuse protection, see documentation for details.

In order to configure IP address filtering, modify your config/initializers/rack-attack.rb file:

if sources = ENV["APP_ALLOW_IP_SOURCES"]
  Rack::Attack.blocklist_ip("0.0.0.0/0") # Reject everything by default
  Rack::Attack.safelist_ip("127.0.0.1")  # Allow all local requests

  sources.split(",").each do |src|
    Rack::Attack.safelist_ip(src)
  end
end

That was easy, right? Now all we need to set the APP_ALLOW_IP_SOURCES environment variable, to something like 1.2.3.4,1.2.3.0/24, and you're good to go!

Pros

  • Plug and play, easy to configure, works with any rack-based application.
  • Ability to use custom Ruby code for access gates.

Cons

  • Requires redis for features like throttling, but we're not using it in the example.

Heroku Private Spaces

Only worth mentioning since Private Spaces are only available on Enterprise plans, which are probably too cost prohibitive for most use-cases unless you're dedicated on staying on the platform for a while. Personally, never used it, but they offer Limit app access to users only on trusted networks. feature. So here's that!

Pros

  • Security is part of the offering

Cons

  • 💰💰💰

Verdict

IP filtering could be done on Heroku! Just use rack-attack, it is good enough to cover all your needs.