Skip to content

Instantly share code, notes, and snippets.

@mudge
Last active November 21, 2023 14:06
  • Star 18 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save mudge/e5c72d701d7fcbda67608deb800193cb to your computer and use it in GitHub Desktop.
How to configure Rails and Rack::Attack to use the real client IP when running behind Cloudflare
Rails.application.configure do
# Add Cloudflare's IPs to the trusted proxy list so they are ignored when
# determining the true client IP.
#
# See https://www.cloudflare.com/ips-v4/ and https://www.cloudflare.com/ips-v6/
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + %w[
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
103.31.4.0/22
141.101.64.0/18
108.162.192.0/18
190.93.240.0/20
188.114.96.0/20
197.234.240.0/22
198.41.128.0/17
162.158.0.0/15
104.16.0.0/13
104.24.0.0/14
172.64.0.0/13
131.0.72.0/22
2400:cb00::/32
2606:4700::/32
2803:f800::/32
2405:b500::/32
2405:8100::/32
2a06:98c0::/29
2c0f:f248::/32
].map { |proxy| IPAddr.new(proxy) }
end
Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.get_header("action_dispatch.remote_ip") unless req.path.start_with?("/assets")
end
@trevorturk
Copy link

trevorturk commented Oct 19, 2023

Just wanted to chime in (and apologies I can't remember where I got this from) but I've been using something like the following with (I believe) pretty good results:

class Rack::Attack
  class Request < ::Rack::Request
    def remote_ip
      @_remote_ip ||= (
        env["HTTP_CF_CONNECTING_IP"] ||
        env["action_dispatch.remote_ip"] ||
        ip
      ).to_s
    end
  end

  throttle("req/ip", limit: 20, period: 1.minute) do |req|
    req.remote_ip unless req.path.start_with?("/assets")
  end
end

@mudge
Copy link
Author

mudge commented Oct 19, 2023

Thanks for sharing, @trevorturk.

I wanted to configure the default request.remote_ip to pick the "real" client IP so that it would be reflected throughout the entire Rails app, e.g. in the default logs:

Started GET "/foo/bar" for 1.2.3.4 at 2023-10-19 18:09:16 +0000
                           ^^^^^^^

It's only a little awkward with Rack::Attack because you don't get a ActionDispatch::Request but a Rack::Request instead. I did consider the following:

Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
  ActionDispatch::Request.new(req.env).remote_ip unless req.path.start_with?("/assets")
end

But felt instantiating a whole ActionDispatch::Request might be a bit overkill when what we want is in the action_dispatch.remote_ip header.

@trevorturk
Copy link

Ah, I hadn't thought of the logs -- nice!

@tmak
Copy link

tmak commented Oct 20, 2023

We use the cloudflare-rails gem successfully in production. It fetches Cloudflare's IPs dynamically from Cloudflare.

@mudge
Copy link
Author

mudge commented Oct 20, 2023

Thanks, @tmak. It looks like that gem works by overriding the trusted_proxy? method in Rack::Request::Helpers and the proxies method of ActionDispatch::RemoteIp (see https://github.com/modosc/cloudflare-rails/blob/main/lib/cloudflare/rails/railtie.rb).

It’s quite nice that it fetches the latest IPs from Cloufflare but I wasn’t sure about the stability of patching internal methods on classes (especially as it uses ObjectSpace to iterate over all loaded classes) vs using a public configuration API. Perhaps a good middle-ground would be to fetch the latest IPs from Cloudflare on application boot but still use Rails.configuration.action_dispatch.trusted_proxies to get the behaviour we want.

@tmak
Copy link

tmak commented Oct 20, 2023

Yes, I feel you.
It uses monkey patching so that your Rails app doesn't have to restart if Cloudflare's IP addresses change.
There are certainly pros and cons to both approaches.

@mudge
Copy link
Author

mudge commented Oct 20, 2023

In practice, if you’re running on Heroku, your app will be restarted daily so that gives you some upper limit of how long your IPs will be potentially outdated if Cloudflare does make a change (I have no sense how often they update their IP ranges).

@tmak
Copy link

tmak commented Oct 20, 2023

True. We're actually running on Heroku, at the moment 😅
The gem's test suite looked solid to me to use a gem, and monkey patching, rather than having to manage this functionality on our own.

Talking about Cloudflare, Heroku, and Rack::Attack. We use this Rack::Attack configuration to prevent skipping Cloudflare in production. May be helpful for you or others who get here.

class Rack::Attack
  if %w[1 true True enabled yes].include?(ENV["REJECT_UNPROXIED_REQUESTS"])
    blocklist("block non-proxied requests in production") do |request|
      raw_ip = request.get_header("HTTP_X_FORWARDED_FOR")
      ip_addresses = raw_ip ? raw_ip.strip.split(/[,\s]+/) : []
      proxy_ip = ip_addresses.last

      if ::Rails.application.config.cloudflare.ips.any? { |proxy|
           proxy === proxy_ip
         }
        false
      else
        ::Rails.logger.warn "Rack Attack IP Filtering: blocked request from #{proxy_ip} to #{request.url}"
        true
      end
    end
  end
end

@trevorturk
Copy link

Ah yes, I do something similar via:

  blocklist("herokuapp") do |req|
    req.host =~ /herokuapp/
  end

...which works for my case, simply enforcing access through my domain as opposed to Heroku directly.

@delphaber
Copy link

Thanks for sharing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment