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.