Rails Rate Limiting

Setting the Scene

I noticed a lot of new “users” in one of my side projects, and immediately wondered what was going on, was this it, had I finally struck the startup gold?! Obviously I hadn’t, but nice to dream for a moment.

On closer inspection, I noticed a barrage of posts to my sign-in form with interesting user names like these:

"cmee7uvb'; waitfor delay '0:0:15' --",
"0qjninbk' or 915=(select 915 from pg_sleep(15))--",
"a77t30jc'; waitfor delay '0:0:15' --",
"kxvmfhk6') or 273=(select 273 from pg_sleep(15))--",
"a9vxyx7i' or 135=(select 135 from pg_sleep(15))--",

Aha! It’s a visit from our old friend Little Bobby Tables!

xkcd.com - Little Bobby Tables xkcd.com

Ok! So now we can see someone with way too much time on their hands is attacking my little site for some nefarious reason or other. Just like Bobby Tables they’re probing and trying to use SQL-injection to cause some kind of mayhem. Thankfully I’m using Rails and the way Rails handles sanitisation of inputs to queries is already really solid.

Still it’s annoying, and it made me remember that there was a new Rate Limiting feature added to Rails 7.2 that I’d not tried out. Great opportunity to do that now.

What is Rate Limiting?

In short, rate limiting is a way to control the number or rate of requests to a service, in my case, HTTP requests to my Rails app. This technique is often used as a first step to restrict the number of requests to a particular endpoint from the same IP. It’s one of many ways to mitigate attacks, but the one we’re looking at today.

The Rails way

Historically I would reach for rack-attack which is a very robust, battle-tested and configurable solution, but since Rails 7.2 we have an option for a simpler way, out of the box with an expressive syntax where we can just add rate_limit in a controller and be done!

class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create
end

This one-liner is all we need to limit any IP to a maximum of 10 requests to our create action over a 3 minute period. delightful. On the 11th request, the server will return a “429 Too Many Requests” response.

We can add a bit more logic to the rule using the by: and with: parameters, where by takes a function to determine what is counted to be rate limited, and with lets us redirect to a specific location with another message. eg.

class SignupsController < ApplicationController
  rate_limit to: 10, within: 3.minutes,
    by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new
end

Lastly there’s a store: parameter you can pass in to specify the ActiveSupport::Cache cache store you want to use. It will default to use config.cache_store from your environment file. In this case, to keep it simple I’ve added config.cache_store = :memory_store to test.rb, development.rb and production.rb, but you can use any ActiveSupport::Cache backend.

Testing

To confirm this works as we expect, lets add a simple Minitest test to confirm the rate limiting kicks in. The first one POST’s an email address to our SessionsController#create action 10 times and asserts we get the expected redirect. It then fires one more POST and we assert that we get the “too many requests” response.

The second test also POST’s to the same action 10 times, but then travels 3 minutes 1 second to the future and confirms it can make a further POST since our 3 minute window has now expired. Neat!

test "should enforce rate limit for create action" do
  10.times do |i|
    post sessions_url, params: { email: "user#{i}@example.com" }
    assert_response :redirect
  end
  
  post sessions_url, params: { email: "overlimit@example.com" }
  assert_response :too_many_requests
end

test "should reset rate limit after the time window" do
  10.times do |i|
    post sessions_url, params: { email: "user#{i}@example.com" }
    assert_response :redirect
  end
  
  travel 3.minutes + 1.second do
    post sessions_url, params: { email: "newuser@example.com" }
    assert_response :redirect
  end
end

One mini gotcha is you may need to add Rails.cache.clear in your test_helper or setup block just to make sure the cache is reset for the tests.

Wrapping Up

Short post this week, but hopefully of use. Don’t hesitate to drop me a note at my new favourite place Blue Sky or via any channels listed here