├── .github ├── dependabot.yml └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .ruby-version ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── cloudflare-rails.gemspec ├── gemfiles ├── .bundle │ └── config ├── rails_7.1.gemfile ├── rails_7.2.gemfile ├── rails_8.0.gemfile └── rails_edge.gemfile ├── lib ├── cloudflare-rails.rb ├── cloudflare_rails.rb └── cloudflare_rails │ ├── check_trusted_proxies.rb │ ├── fallback_ips.rb │ ├── importer.rb │ ├── railtie.rb │ ├── remote_ip_proxies.rb │ └── version.rb └── spec ├── cloudflare └── rails_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | pull_request: 12 | branches: [main] 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | ruby: ["3.2", "3.3", "3.4"] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | - uses: actions/cache@v3 26 | with: 27 | path: vendor/bundle 28 | key: ${{ runner.os }}-gems-${{ hashFiles('**/cloudflare-rails.gemspec') }} 29 | restore-keys: | 30 | ${{ runner.os }}-gems- 31 | - name: Bundle install 32 | run: | 33 | bundle config path vendor/bundle 34 | bundle install --jobs 4 --retry 3 35 | - name: Appraisal install 36 | run: | 37 | bundle exec appraisal install 38 | - name: Run tests 39 | run: bundle exec appraisal rake 40 | lint: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: "3.4" # Not needed with a .ruby-version file 47 | - uses: actions/cache@v3 48 | with: 49 | path: vendor/bundle 50 | key: ${{ runner.os }}-gems-${{ hashFiles('**/cloudflare-rails.gemspec') }} 51 | restore-keys: | 52 | ${{ runner.os }}-gems- 53 | - name: Bundle install 54 | run: | 55 | bundle config path vendor/bundle 56 | bundle install --jobs 4 --retry 3 57 | - name: Rubocop 58 | run: bundle exec rubocop 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /log/ 11 | .ruby-gemset 12 | .DS_Store 13 | 14 | *.gemfile.lock 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format d 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop_todo.yml 3 | require: 4 | - rubocop-rails 5 | - rubocop-performance 6 | - rubocop-rspec 7 | 8 | AllCops: 9 | NewCops: enable 10 | TargetRubyVersion: 3.2 11 | SuggestExtensions: false 12 | 13 | Style/Documentation: 14 | Enabled: false 15 | 16 | Gemspec/DevelopmentDependencies: 17 | Enabled: false 18 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2024-06-13 00:13:47 UTC using RuboCop version 1.64.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: AllowedMethods. 11 | # AllowedMethods: enums 12 | Lint/ConstantDefinitionInBlock: 13 | Exclude: 14 | - "spec/cloudflare/rails_spec.rb" 15 | 16 | # Offense count: 1 17 | # This cop supports unsafe autocorrection (--autocorrect-all). 18 | # Configuration parameters: AllowedMethods. 19 | # AllowedMethods: instance_of?, kind_of?, is_a?, eql?, respond_to?, equal?, presence, present? 20 | Lint/RedundantSafeNavigation: 21 | Exclude: 22 | - "spec/cloudflare/rails_spec.rb" 23 | 24 | # Offense count: 1 25 | # Configuration parameters: AllowComments, AllowNil. 26 | Lint/SuppressedException: 27 | Exclude: 28 | - "lib/cloudflare_rails/check_trusted_proxies.rb" 29 | 30 | # Offense count: 1 31 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 32 | Metrics/AbcSize: 33 | Max: 18 34 | 35 | # Offense count: 2 36 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 37 | Metrics/MethodLength: 38 | Max: 11 39 | 40 | # Offense count: 1 41 | # Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. 42 | # CheckDefinitionPathHierarchyRoots: lib, spec, test, src 43 | # AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS 44 | Naming/FileName: 45 | Exclude: 46 | - "lib/cloudflare-rails.rb" 47 | 48 | # Offense count: 2 49 | # Configuration parameters: ForbiddenDelimiters. 50 | # ForbiddenDelimiters: (?i-mx:(^|\s)(EO[A-Z]{1}|END)(\s|$)) 51 | Naming/HeredocDelimiterNaming: 52 | Exclude: 53 | - "lib/cloudflare_rails/fallback_ips.rb" 54 | 55 | # Offense count: 3 56 | RSpec/AnyInstance: 57 | Exclude: 58 | - "spec/cloudflare/rails_spec.rb" 59 | 60 | # Offense count: 1 61 | # Configuration parameters: Prefixes, AllowedPatterns. 62 | # Prefixes: when, with, without 63 | RSpec/ContextWording: 64 | Exclude: 65 | - "spec/cloudflare/rails_spec.rb" 66 | 67 | # Offense count: 2 68 | # This cop supports safe autocorrection (--autocorrect). 69 | # Configuration parameters: CustomTransform, IgnoredWords, DisallowedExamples. 70 | # DisallowedExamples: works 71 | RSpec/ExampleWording: 72 | Exclude: 73 | - "spec/cloudflare/rails_spec.rb" 74 | 75 | # Offense count: 1 76 | RSpec/LeakyConstantDeclaration: 77 | Exclude: 78 | - "spec/cloudflare/rails_spec.rb" 79 | 80 | # Offense count: 5 81 | RSpec/MultipleExpectations: 82 | Max: 2 83 | 84 | # Offense count: 15 85 | # Configuration parameters: AllowSubject. 86 | RSpec/MultipleMemoizedHelpers: 87 | Max: 14 88 | 89 | # Offense count: 3 90 | # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. 91 | # SupportedStyles: always, named_only 92 | RSpec/NamedSubject: 93 | Exclude: 94 | - "spec/cloudflare/rails_spec.rb" 95 | 96 | # Offense count: 16 97 | # Configuration parameters: AllowedGroups. 98 | RSpec/NestedGroups: 99 | Max: 6 100 | 101 | # Offense count: 1 102 | # Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. 103 | # Include: **/*_spec.rb 104 | RSpec/SpecFilePathFormat: 105 | Exclude: 106 | - "spec/cloudflare/rails_spec.rb" 107 | 108 | # Offense count: 1 109 | # This cop supports unsafe autocorrection (--autocorrect-all). 110 | Rails/ApplicationController: 111 | Exclude: 112 | - "spec/cloudflare/rails_spec.rb" 113 | 114 | # Offense count: 1 115 | # This cop supports unsafe autocorrection (--autocorrect-all). 116 | Rails/CompactBlank: 117 | Exclude: 118 | - "lib/cloudflare_rails/importer.rb" 119 | 120 | # Offense count: 3 121 | # This cop supports unsafe autocorrection (--autocorrect-all). 122 | # Configuration parameters: Include. 123 | # Include: **/Rakefile, **/*.rake 124 | Rails/RakeEnvironment: 125 | Exclude: 126 | - "Rakefile" 127 | 128 | # Offense count: 1 129 | # This cop supports safe autocorrection (--autocorrect). 130 | # Configuration parameters: AllowOnConstant, AllowOnSelfClass. 131 | Style/CaseEquality: 132 | Exclude: 133 | - "lib/cloudflare_rails/check_trusted_proxies.rb" 134 | 135 | # Offense count: 2 136 | # This cop supports safe autocorrection (--autocorrect). 137 | # Configuration parameters: MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. 138 | # SupportedStyles: annotated, template, unannotated 139 | # AllowedMethods: redirect 140 | Style/FormatStringToken: 141 | EnforcedStyle: unannotated 142 | 143 | # Offense count: 13 144 | # This cop supports unsafe autocorrection (--autocorrect-all). 145 | # Configuration parameters: EnforcedStyle. 146 | # SupportedStyles: always, always_true, never 147 | Style/FrozenStringLiteralComment: 148 | Exclude: 149 | - "Appraisals" 150 | - "Gemfile" 151 | - "Rakefile" 152 | - "cloudflare-rails.gemspec" 153 | - "gemfiles/rails_7.1.gemfile" 154 | 155 | # Offense count: 1 156 | Style/MultilineBlockChain: 157 | Exclude: 158 | - "lib/cloudflare_rails/railtie.rb" 159 | 160 | # Offense count: 2 161 | # This cop supports safe autocorrection (--autocorrect). 162 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. 163 | # URISchemes: http, https 164 | Layout/LineLength: 165 | Max: 126 166 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-7.1' do 2 | gem 'rails', '~> 7.1.0' 3 | end 4 | 5 | appraise 'rails-7.2' do 6 | gem 'rails', '~> 7.2.0' 7 | end 8 | 9 | appraise 'rails-8.0' do 10 | gem 'rails', '~> 8.0.2' 11 | end 12 | 13 | appraise 'rails-edge' do 14 | gem 'rails', github: 'rails/rails' 15 | end 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | ## [6.2.0] 7 | - Trust X-Forwarded-For from the right to the left (https://github.com/modosc/cloudflare-rails/pull/162) 8 | 9 | ## [6.1.0] 10 | - Add cloudflare? method to determine if request passed through CF (https://github.com/modosc/cloudflare-rails/pull/149) 11 | 12 | ## [6.0.0] - 2024-06-12 13 | - Drop support for `rails` version `6.1` and `7.0`, new minimum version is `7.1.0` (https://github.com/modosc/cloudflare-rails/pull/142) 14 | - Bump minimum ruby version to `3.1.0` in preparation for `rails` version `7.2` (https://github.com/modosc/cloudflare-rails/pull/142) 15 | - Relax `rails` dependencies to allow for `7.2` and `8.0` (https://github.com/modosc/cloudflare-rails/pull/142) 16 | - Fix `Appraisals` branch for `rails` version `7.2` (https://github.com/modosc/cloudflare-rails/pull/142) 17 | - add `rails` version `8.0` to `Appraisals` (https://github.com/modosc/cloudflare-rails/pull/142) 18 | 19 | ## [5.0.1] - 2023-12-16 20 | - Fix `zeitwerk` loading issue (https://github.com/modosc/cloudflare-rails/pull/105) 21 | 22 | ## [5.0.0] - 2023-12-15 23 | ### Breaking Changes 24 | - Change namespace from `Cloudflare::Rails` to `CloudflareRails`. This avoids issues with the [cloudflare](https://github.com/socketry/cloudflare) gem as well as the global `Rails` namespace. 25 | - A static set of Cloudflare IP addresses will now be used as a fallback value in the case of Cloudflare API failures. These will not be stored in `Rails.cache` so each subsequent result will retry the Cloudflare calls. Once one suceeds the response will be cached and used. 26 | 27 | ### Added 28 | - Use `zeitwerk` to manage file loading. 29 | 30 | ## [4.1.0] - 2023-10-06 31 | - Add support for `rails` version `7.1.0` 32 | 33 | ## [4.0.0] - 2023-08-06 34 | - Fix `appraisal` for ruby `3.x` 35 | - properly scope railtie initializer (https://github.com/modosc/cloudflare-rails/pull/79) 36 | - Drop support for unsupported `rails` version `6.0.x` 37 | 38 | ## [3.0.0] - 2023-01-30 39 | - Drop support for unsupported `rails` version `5.2.x` 40 | - Fetch and cache IPs lazily instead of upon initialization (https://github.com/modosc/cloudflare-rails/pull/52) 41 | 42 | ## [2.4.0] - 2022-02-22 43 | - Add trailing slashes to reflect Cloudflare API URLs (https://github.com/modosc/cloudflare-rails/pull/53) 44 | 45 | ## [2.3.0] - 2021-10-22 46 | - Better handling of malformed IP addresses (https://github.com/modosc/cloudflare-rails/pull/49) 47 | 48 | ## [2.2.0] - 2021-06-11 49 | - Fix typo in `actionpack` dependency 50 | 51 | ## [2.1.0] - 2021-06-11 52 | ### Breaking Changes 53 | - Drop support for unsupported `rails` versions (`5.0.x` and `5.1.x`) 54 | 55 | ### Added 56 | - use Net::HTTP instead of httparty ([pr](https://github.com/modosc/cloudflare-rails/pull/44)) 57 | - Add `rails 7.0.0.alpha` support 58 | 59 | ## [2.0.0] - 2021-02-17 60 | ### Breaking Changes 61 | - Removed broad dependency on `rails`, replaced with explicit dependencies for `railties`, `activesupport`, and `actionpack` ( [issue](https://github.com/modosc/cloudflare-rails/issues/34) and [pr](https://github.com/modosc/cloudflare-rails/pull/35)) 62 | 63 | ## [1.0.0] - 2020-09-29 64 | ### Added 65 | 66 | - Fix various [loading order issues](https://github.com/modosc/cloudflare-rails/pull/25). 67 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in cloudflare-rails.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2017 jonathan schatz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudflareRails [![Gem Version](https://badge.fury.io/rb/cloudflare-rails.svg)](https://badge.fury.io/rb/cloudflare-rails) 2 | 3 | This gem correctly configures Rails for [CloudFlare](https://www.cloudflare.com) so that `request.remote_ip` / `request.ip` both work correctly. It also exposes a `#cloudflare?` method on `Rack::Request`. 4 | 5 | ## Rails Compatibility 6 | 7 | This gem requires `railties`, `activesupport`, and `actionpack` >= `7.1`. For older `rails` versions see the chart below: 8 | 9 | | `rails` version | `cloudflare-rails` version | 10 | | --------------- | -------------------------- | 11 | | 7.0 | 5.0.1 | 12 | | 6.1 | 5.0.1 | 13 | | 6.0 | 3.0.0 | 14 | | 5.2 | 2.4.0 | 15 | | 5.1 | 2.0.0 | 16 | | 5.0 | 2.0.0 | 17 | | 4.2 | 0.1.0 | 18 | 19 | ## Installation 20 | 21 | Add this line to your application's `Gemfile`: 22 | 23 | ```ruby 24 | group :production do 25 | # or :staging or :beta or whatever environments you are using cloudflare in. 26 | # you probably don't want this for :test or :development 27 | gem 'cloudflare-rails' 28 | end 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | 35 | ### If you're using Kamal 36 | 37 | If you're using Kamal 2 for deployments, `kamal-proxy` [won't forward headers to your Rails app while using SSL]([url](https://kamal-deploy.org/docs/configuration/proxy/#forward-headers)), unless you explicitly tell it to. Without this, `cloudflare-rails` won't work in a Kamal-deployed Rails app using SSL. 38 | 39 | You need to add `forward_headers: true` to your `proxy` section, like this: 40 | ```yaml 41 | proxy: 42 | ssl: true 43 | host: example.com 44 | forward_headers: true 45 | ``` 46 | 47 | ## Problem 48 | 49 | Using Cloudflare means it's hard to identify the IP address of incoming requests since all requests are proxied through Cloudflare's infrastructure. Cloudflare provides a [CF-Connecting-IP](https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-) header which can be used to identify the originating IP address of a request. However, this header alone doesn't verify a request is legitimate. If an attacker has found the actual IP address of your server they could spoof this header and masquerade as legitimate traffic. 50 | 51 | `cloudflare-rails` mitigates this attack by checking that the originating ip address of any incoming connection is from one of Cloudflare's ip address ranges. If so, the incoming `X-Forwarded-For` header is trusted and used as the ip address provided to `rack` and `rails` (via `request.ip` and `request.remote_ip`). If the incoming connection does not originate from a Cloudflare server then the `X-Forwarded-For` header is ignored and the actual remote ip address is used. 52 | 53 | ## How it works 54 | 55 | This code fetches and caches CloudFlare's current [IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6) lists. It then patches `Rack::Request::Helpers` and `ActionDispatch::RemoteIP` to treat these addresses as trusted proxies. The `X-Forwarded-For` header will then be trusted only from those ip addresses. 56 | 57 | ### Why not use `config.action_dispatch.trusted_proxies` or `Rack::Request.ip_filter?` 58 | 59 | By default Rails includes the [ActionDispatch::RemoteIp](https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html) middleware. This middleware uses a default list of [trusted proxies](https://github.com/rails/rails/blob/6b93fff8af32ef5e91f4ec3cfffb081d0553faf0/actionpack/lib/action_dispatch/middleware/remote_ip.rb#L36C5-L42). Any values from `config.action_dispatch.trusted_proxies` are appended to this list. If you were to set `config.action_dispatch.trusted_proxies` to the current list of Cloudflare IP addresses `request.remote_ip` would work correctly. 60 | 61 | Unfortunately this does not fix `request.ip`. This method comes from the [Rack::Request](https://github.com/rack/rack/blob/main/lib/rack/request.rb) middleware. It has a separate implementation of [trusted proxies](https://github.com/rack/rack/blob/main/lib/rack/request.rb#L48-L56) and [ip filtering](https://github.com/rack/rack/blob/main/lib/rack/request.rb#L58C1-L59C1). The only way to use a different implementation is to set `Rack::Request.ip_filter` which expects a callable value. Providing a new one will override the old one so you'd lose the default values (all of which should be there). Those values aren't exported anywhere so your callable would now have to maintain _that_ list on top of the Cloudflare IPs. 62 | 63 | These issues are why this gem patches both `Rack::Request::Helpers` and `ActionDispatch::RemoteIP` rather than using the built-in configuration methods. 64 | 65 | ## Prerequisites 66 | 67 | You must have a [`cache_store`](https://guides.rubyonrails.org/caching_with_rails.html#configuration) configured in your `rails` application. 68 | 69 | ## Usage 70 | 71 | You can configure the HTTP `timeout` and `expires_in` cache parameters inside of your `rails` config: 72 | 73 | ```ruby 74 | config.cloudflare.expires_in = 12.hours # default value 75 | config.cloudflare.timeout = 5.seconds # default value 76 | ``` 77 | 78 | ## Blocking non-Cloudflare traffic 79 | 80 | You can use the `#cloudflare?` method from this gem to block all non-Cloudflare traffic to your application. Here's an example of doing this with [`Rack::Attack`](https://github.com/rack/rack-attack): 81 | 82 | ```ruby 83 | Rack::Attack.blocklist('CloudFlare WAF bypass') do |req| 84 | !req.cloudflare? 85 | end 86 | ``` 87 | 88 | Note that the request may optionally pass through additional trusted proxies, so it will return `true` for any of these scenarios: 89 | 90 | - `REMOTE_ADDR: CloudFlare` 91 | - `REMOTE_ADDR: trusted_proxy`, `X_HTTP_FORWARDED_FOR: CloudFlare` 92 | - `REMOTE_ADDR: trusted_proxy`, `X_HTTP_FORWARDED_FOR: CloudFlare,trusted_proxy2` 93 | - `REMOTE_ADDR: trusted_proxy`, `X_HTTP_FORWARDED_FOR: untrusted,CloudFlare` 94 | 95 | but it will return `false` if CloudFlare comes to the left of an untrusted IP in `X-Forwarded-For`. 96 | 97 | ## Development 98 | 99 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 100 | 101 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 102 | 103 | ## Contributing 104 | 105 | Bug reports and pull requests are welcome on GitHub at https://github.com/modosc/cloudflare-rails. 106 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :without_rack_attack do 7 | ENV.delete 'RACK_ATTACK' 8 | Rake::Task['spec'].reenable 9 | Rake::Task['spec'].invoke 10 | end 11 | 12 | task :with_rack_attack_first do 13 | ENV['RACK_ATTACK'] = 'first' 14 | Rake::Task['spec'].reenable 15 | Rake::Task['spec'].invoke 16 | end 17 | 18 | task :with_rack_attack_last do 19 | ENV['RACK_ATTACK'] = 'last' 20 | Rake::Task['spec'].reenable 21 | Rake::Task['spec'].invoke 22 | end 23 | 24 | task default: %i[without_rack_attack with_rack_attack_first with_rack_attack_last] 25 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "cloudflare/rails" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /cloudflare-rails.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'cloudflare_rails/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'cloudflare-rails' 7 | spec.version = CloudflareRails::VERSION 8 | spec.authors = ['jonathan schatz'] 9 | spec.email = ['modosc@users.noreply.github.com'] 10 | spec.summary = 'This gem configures Rails for CloudFlare so that request.ip and request.remote_ip and work correctly.' 11 | spec.description = '' 12 | spec.homepage = 'https://github.com/modosc/cloudflare-rails' 13 | spec.license = 'MIT' 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 16 | spec.bindir = 'exe' 17 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_development_dependency 'appraisal', '~> 2.5.0' 21 | spec.add_development_dependency 'bundler', '>= 2.4.18' 22 | spec.add_development_dependency 'pry-byebug' 23 | spec.add_development_dependency 'rack-attack', '~> 6.7.0' 24 | spec.add_development_dependency 'rake', '~> 13.2.1' 25 | spec.add_development_dependency 'rspec', '~> 3.13.0' 26 | spec.add_development_dependency 'rspec-rails', '~> 7.1.1' 27 | spec.add_development_dependency 'rubocop', '~> 1.75.2' 28 | spec.add_development_dependency 'rubocop-performance', '~> 1.25.0' 29 | spec.add_development_dependency 'rubocop-rails', '~> 2.31.0' 30 | spec.add_development_dependency 'rubocop-rspec', '~> 3.6.0' 31 | spec.add_development_dependency 'webmock', '~> 3.25.0' 32 | spec.add_dependency 'actionpack', '>= 7.1.0', '< 8.1.0' 33 | spec.add_dependency 'activesupport', '>= 7.1.0', '< 8.1.0' 34 | spec.add_dependency 'railties', '>= 7.1.0', '< 8.1.0' 35 | spec.add_dependency 'zeitwerk', '>= 2.5.0' 36 | 37 | spec.required_ruby_version = '>= 3.2.0' 38 | spec.metadata['rubygems_mfa_required'] = 'true' 39 | end 40 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rails', '~> 7.1.0' 6 | 7 | gemspec path: '../' 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'rails', '~> 7.2.0' 8 | 9 | gemspec path: '../' 10 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'rails', '~> 8.0.2' 8 | 9 | gemspec path: '../' 10 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'rails', github: 'rails/rails' 8 | 9 | gemspec path: '../' 10 | -------------------------------------------------------------------------------- /lib/cloudflare-rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudflare_rails' 4 | -------------------------------------------------------------------------------- /lib/cloudflare_rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'zeitwerk' 4 | loader = Zeitwerk::Loader.for_gem 5 | loader.ignore("#{__dir__}/cloudflare-rails.rb") 6 | loader.setup 7 | 8 | module CloudflareRails 9 | end 10 | 11 | loader.eager_load 12 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/check_trusted_proxies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudflareRails 4 | # patch rack::request::helpers to use our cloudflare ips - this way request.ip is 5 | # correct inside of rack and rails 6 | module CheckTrustedProxies 7 | def cloudflare_ip?(ip) 8 | Importer.cloudflare_ips.any? do |proxy| 9 | proxy === ip 10 | rescue IPAddr::InvalidAddressError 11 | end 12 | end 13 | 14 | def trusted_proxy?(ip) 15 | cloudflare_ip?(ip) || super 16 | end 17 | 18 | def cloudflare? 19 | remote_addresses = split_header(get_header('REMOTE_ADDR')) 20 | forwarded_for = self.forwarded_for || [] 21 | 22 | # Select only the trusted prefix of REMOTE_ADDR + X_HTTP_FORWARDED_FOR 23 | trusted_proxies = (remote_addresses + forwarded_for.reverse).take_while do |ip| 24 | trusted_proxy?(ip) 25 | end 26 | 27 | trusted_proxies.any? do |ip| 28 | cloudflare_ip?(ip) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/fallback_ips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudflareRails 4 | module FallbackIps 5 | # fetched from https://www.cloudflare.com/ips-v4/ on 2023-12-10 6 | IPS_V4_BODY = <<~EOM 7 | 173.245.48.0/20 8 | 103.21.244.0/22 9 | 103.22.200.0/22 10 | 103.31.4.0/22 11 | 141.101.64.0/18 12 | 108.162.192.0/18 13 | 190.93.240.0/20 14 | 188.114.96.0/20 15 | 197.234.240.0/22 16 | 198.41.128.0/17 17 | 162.158.0.0/15 18 | 104.16.0.0/13 19 | 104.24.0.0/14 20 | 172.64.0.0/13 21 | 131.0.72.0/22 22 | EOM 23 | 24 | # convert our body into a list of IpAddrs 25 | IPS_V4 = IPS_V4_BODY.split("\n").map { |ip| IPAddr.new ip }.freeze 26 | 27 | # from https://www.cloudflare.com/ips-v6/ on 2023-12-10 28 | IPS_V6_BODY = <<~EOM 29 | 2400:cb00::/32 30 | 2606:4700::/32 31 | 2803:f800::/32 32 | 2405:b500::/32 33 | 2405:8100::/32 34 | 2a06:98c0::/29 35 | 2c0f:f248::/32 36 | EOM 37 | 38 | # convert our body into a list of IpAddrs 39 | IPS_V6 = IPS_V6_BODY.split("\n").map { |ip| IPAddr.new ip }.freeze 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'uri' 5 | 6 | module CloudflareRails 7 | class Importer 8 | # Exceptions contain the Net::HTTP 9 | # response object accessible via the {#response} method. 10 | class ResponseError < StandardError 11 | # Returns the response of the last request 12 | # @return [Net::HTTPResponse] A subclass of Net::HTTPResponse, e.g. 13 | # Net::HTTPOK 14 | attr_reader :response 15 | 16 | # Instantiate an instance of ResponseError with a Net::HTTPResponse object 17 | # @param [Net::HTTPResponse] 18 | def initialize(response) 19 | @response = response 20 | super 21 | end 22 | end 23 | 24 | BASE_URL = 'https://www.cloudflare.com' 25 | IPS_V4_URL = '/ips-v4/' 26 | IPS_V6_URL = '/ips-v6/' 27 | 28 | class << self 29 | def ips_v6 30 | fetch IPS_V6_URL 31 | end 32 | 33 | def ips_v4 34 | fetch IPS_V4_URL 35 | end 36 | 37 | def fetch(url) 38 | uri = URI("#{BASE_URL}#{url}") 39 | 40 | resp = Net::HTTP.start(uri.host, 41 | uri.port, 42 | use_ssl: true, 43 | read_timeout: Rails.application.config.cloudflare.timeout) do |http| 44 | req = Net::HTTP::Get.new(uri) 45 | 46 | http.request(req) 47 | end 48 | 49 | raise ResponseError, resp unless resp.is_a?(Net::HTTPSuccess) 50 | 51 | resp.body.split("\n").reject(&:blank?).map { |ip| IPAddr.new ip } 52 | end 53 | 54 | def fetch_with_cache(type) 55 | Rails.cache.fetch("cloudflare-rails:#{type}", expires_in: Rails.application.config.cloudflare.expires_in) do 56 | send type 57 | end 58 | end 59 | 60 | def cloudflare_ips(refresh: false) 61 | @ips = nil if refresh 62 | @ips ||= (Importer.fetch_with_cache(:ips_v4) + Importer.fetch_with_cache(:ips_v6)).freeze 63 | rescue StandardError => e 64 | Rails.logger.error "cloudflare-rails: error fetching ip addresses from Cloudflare (#{e}), falling back to defaults" 65 | CloudflareRails::FallbackIps::IPS_V4 + CloudflareRails::FallbackIps::IPS_V6 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | module CloudflareRails 6 | class Railtie < Rails::Railtie 7 | # setup defaults before we configure our app. 8 | DEFAULTS = { 9 | expires_in: 12.hours, 10 | timeout: 5.seconds 11 | }.freeze 12 | 13 | config.before_configuration do |app| 14 | app.config.cloudflare = ActiveSupport::OrderedOptions.new 15 | app.config.cloudflare.reverse_merge! DEFAULTS 16 | end 17 | 18 | initializer 'cloudflare_rails.configure_rails_initialization' do 19 | Rack::Request::Helpers.prepend CheckTrustedProxies 20 | 21 | ObjectSpace.each_object(Class) 22 | .select do |c| 23 | c.included_modules.include?(Rack::Request::Helpers) && 24 | c.included_modules.exclude?(CheckTrustedProxies) 25 | end 26 | .map { |c| c.prepend CheckTrustedProxies } 27 | 28 | ActionDispatch::RemoteIp.prepend RemoteIpProxies 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/remote_ip_proxies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudflareRails 4 | # patch ActionDispatch::RemoteIP to use our cloudflare ips - this way 5 | # request.remote_ip is correct inside of rails 6 | module RemoteIpProxies 7 | def proxies 8 | super + Importer.cloudflare_ips 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/cloudflare_rails/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudflareRails 4 | VERSION = '6.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/cloudflare/rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe CloudflareRails do 6 | context(format('%s rack-attack %s', ENV['RACK_ATTACK'] ? 'with' : 'without', ENV.fetch('RACK_ATTACK', nil))) do 7 | it 'has a version number' do 8 | expect(CloudflareRails::VERSION).not_to be_nil 9 | end 10 | 11 | describe 'Railtie' do 12 | let!(:rails_app) do 13 | ActiveSupport::Dependencies.autoload_once_paths = [] 14 | ActiveSupport::Dependencies.autoload_paths = [] 15 | Class.new(Rails::Application) do 16 | config.load_defaults Rails.gem_version.version.to_f 17 | config.eager_load = false 18 | config.active_support.deprecation = :stderr 19 | config.middleware.use Rack::Attack if ENV['RACK_ATTACK'] 20 | end 21 | end 22 | 23 | # these are the same bodies as our fallbacks _except_ we remove one entry from each. 24 | # this way we can tell when we use the fallback values and whwn we're using the (mocked) 25 | # return values from our http calls 26 | let(:ips_v4_body) do 27 | ips_v4 = CloudflareRails::FallbackIps::IPS_V4_BODY.dup.split("\n") 28 | ips_v4.shift 29 | "#{ips_v4.join("\n")}\n" 30 | end 31 | 32 | let(:ips_v6_body) do 33 | ips_v6 = CloudflareRails::FallbackIps::IPS_V6_BODY.dup.split("\n") 34 | ips_v6.shift 35 | "#{ips_v6.join("\n")}\n" 36 | end 37 | 38 | let(:ips_v4_status) { 200 } 39 | let(:ips_v6_status) { 200 } 40 | 41 | before do 42 | if ENV['RACK_ATTACK'] 43 | Rack::Attack.throttle('requests per ip', limit: 300, period: 5.minutes) do |request| 44 | # the request object is a Rack::Request 45 | # https://www.rubydoc.info/gems/rack/Rack/Request 46 | request.ip unless request.path.start_with? '/assets/' 47 | end 48 | end 49 | 50 | stub_request(:get, 'https://www.cloudflare.com/ips-v4/') 51 | .to_return(status: ips_v4_status, body: ips_v4_body) 52 | 53 | stub_request(:get, 'https://www.cloudflare.com/ips-v6/') 54 | .to_return(status: ips_v6_status, body: ips_v6_body) 55 | end 56 | 57 | after do 58 | # clear our cache just in case (and if possible) 59 | Rails&.cache&.clear 60 | end 61 | 62 | if ENV['RACK_ATTACK'] 63 | it 'monkey-patches rack-attack' do 64 | rails_app.initialize! 65 | expect(Rack::Attack::Request.included_modules).to include(CloudflareRails::CheckTrustedProxies) 66 | end 67 | end 68 | 69 | describe 'CloudflareRails::Importer' do 70 | subject { CloudflareRails::Importer.cloudflare_ips(refresh: true) } 71 | 72 | it 'works with valid responses' do 73 | expect_any_instance_of(Logger).not_to receive(:error) 74 | rails_app.initialize! 75 | expect(subject) 76 | .to eq((ips_v4_body + ips_v6_body).split("\n").map { |ip| IPAddr.new ip }) 77 | end 78 | 79 | describe 'with unsuccessful responses' do 80 | let(:ips_v4_status) { 404 } 81 | let(:ips_v6_status) { 404 } 82 | 83 | it "doesn't break, logs the error, and returns the fallback values" do 84 | expect_any_instance_of(Logger).to receive(:error).once.and_call_original 85 | rails_app.initialize! 86 | expect(subject) 87 | .to eq(CloudflareRails::FallbackIps::IPS_V4 + CloudflareRails::FallbackIps::IPS_V6) 88 | end 89 | end 90 | 91 | describe 'with invalid bodies' do 92 | let(:ips_v4_body) { 'asdfasdfasdfasdfasdf' } 93 | let(:ips_v6_body) { "\r\n\r\n\r\n" } 94 | 95 | it "doesn't break but still logs the error" do 96 | expect_any_instance_of(Logger).to receive(:error).once.and_call_original 97 | rails_app.initialize! 98 | expect(subject) 99 | .to eq(CloudflareRails::FallbackIps::IPS_V4 + CloudflareRails::FallbackIps::IPS_V6) 100 | end 101 | end 102 | end 103 | 104 | describe 'Rack::Request' do 105 | before do 106 | rails_app.initialize! 107 | end 108 | 109 | describe '#cloudflare?' do 110 | it 'returns true if the request originated from CloudFlare directly' do 111 | expect(Rack::Request.new('REMOTE_ADDR' => '197.234.240.1')).to be_cloudflare 112 | end 113 | 114 | it 'returns true if the request originated from CloudFlare via one trusted proxy' do 115 | expect(Rack::Request.new('REMOTE_ADDR' => '10.1.1.1', 'HTTP_X_FORWARDED_FOR' => '197.234.240.1')).to be_cloudflare 116 | end 117 | 118 | it 'returns true if the request originated from CloudFlare via two trusted proxies' do 119 | expect(Rack::Request.new('REMOTE_ADDR' => '10.1.1.1', 120 | 'HTTP_X_FORWARDED_FOR' => '10.2.2.2,197.234.240.1')).to be_cloudflare 121 | end 122 | 123 | it 'returns true if the right-most addresses in the forwarding chain are trusted proxies and include CloudFlare' do 124 | expect(Rack::Request.new('REMOTE_ADDR' => '10.1.1.1', 125 | 'HTTP_X_FORWARDED_FOR' => '1.2.3.4,10.2.2.2,197.234.240.1')).to be_cloudflare 126 | end 127 | 128 | it 'returns false if the request went through an untrusted IP address after Cloudflare' do 129 | expect(Rack::Request.new('REMOTE_ADDR' => '10.1.1.1', 130 | 'HTTP_X_FORWARDED_FOR' => '197.234.240.1,1.2.3.4')).not_to be_cloudflare 131 | end 132 | 133 | it 'returns false if the request did not originate from CloudFlare' do 134 | expect(Rack::Request.new('REMOTE_ADDR' => '1.2.3.4')).not_to be_cloudflare 135 | end 136 | 137 | it 'returns false if the request originated from CloudFlare via an untrusted REMOTE_ADDR' do 138 | expect(Rack::Request.new('REMOTE_ADDR' => '1.2.3.4', 139 | 'HTTP_X_FORWARDED_FOR' => '197.234.240.1')).not_to be_cloudflare 140 | end 141 | 142 | it 'returns false if the request has a trusted REMOTE_ADDR but did not originate from CloudFlare' do 143 | expect(Rack::Request.new('REMOTE_ADDR' => '10.1.1.1', 'HTTP_X_FORWARDED_FOR' => '1.2.3.4')).not_to be_cloudflare 144 | end 145 | end 146 | end 147 | 148 | # functional tests - maybe duplicate of the remote_ip/ip tests above? 149 | describe 'middleware', type: :request do 150 | let(:base_ip) { '1.2.3.4' } 151 | let(:non_cf_ip) { '8.8.4.4' } 152 | let(:cf_ip) { '197.234.240.1' } 153 | let(:cf_env) do 154 | { 155 | 'HTTP_X_FORWARDED_FOR' => "#{base_ip}, #{cf_ip}", 156 | 'REMOTE_ADDR' => cf_ip 157 | } 158 | end 159 | let(:non_cf_env) do 160 | { 161 | 'HTTP_X_FORWARDED_FOR' => "#{base_ip}, #{non_cf_ip}", 162 | 'REMOTE_ADDR' => non_cf_ip 163 | } 164 | end 165 | let(:cf_proxy_env) do 166 | { 167 | 'HTTP_X_FORWARDED_FOR' => "#{base_ip}, #{cf_ip}, 127.0.0.1", 168 | 'REMOTE_ADDR' => '127.0.0.1' 169 | } 170 | end 171 | let(:non_cf_proxy_env) do 172 | { 173 | 'HTTP_X_FORWARDED_FOR' => "#{base_ip}, #{non_cf_ip}, 127.0.0.1", 174 | 'REMOTE_ADDR' => '127.0.0.1' 175 | } 176 | end 177 | 178 | before do 179 | class FooController < ActionController::Base 180 | def index 181 | render status: :ok, json: { ip: request.ip, remote_ip: request.remote_ip } 182 | end 183 | end 184 | 185 | rails_app.initialize! 186 | rails_app.routes.draw do 187 | root to: 'foo#index', format: 'json' 188 | end 189 | end 190 | 191 | # based on code from https://github.com/rails/rails/blob/7f18ea14c893cb5c9f04d4fda9661126758332b5/railties/test/application/middleware/remote_ip_test.rb 192 | def remote_ip(env = {}) 193 | remote_ip = nil 194 | env = Rack::MockRequest.env_for('/').merge(env).merge!( 195 | 'action_dispatch.show_exceptions' => false, 196 | 'action_dispatch.key_generator' => ActiveSupport::KeyGenerator.new('b3c631c314c0bbca50c1b2843150fe33') 197 | ) 198 | 199 | endpoint = proc do |e| 200 | remote_ip = ActionDispatch::Request.new(e).remote_ip 201 | [200, {}, [remote_ip]] 202 | end 203 | 204 | rails_app.middleware.build(endpoint).call(env) 205 | # return our ip _and_ our env hash 206 | [remote_ip, env] 207 | end 208 | 209 | def ip(env = {}) 210 | ip = nil 211 | env = Rack::MockRequest.env_for('/').merge(env).merge!( 212 | 'action_dispatch.show_exceptions' => false, 213 | 'action_dispatch.key_generator' => ActiveSupport::KeyGenerator.new('b3c631c314c0bbca50c1b2843150fe33') 214 | ) 215 | 216 | endpoint = proc do |e| 217 | ip = ActionDispatch::Request.new(e).ip 218 | [200, {}, [ip]] 219 | end 220 | 221 | rails_app.middleware.build(endpoint).call(env) 222 | # return our ip _and_ our env hash 223 | [ip, env] 224 | end 225 | 226 | # test two different ways: 227 | # 228 | # 1) using the ip/remote_ip methods from above 229 | # 2) using a functional test with the ip/remote_ip embedded in the response 230 | # payload - this probably isn't necessary but i don't 100% understand 231 | # what the copied remote_ip code from the rails tests is actually doing. 232 | 233 | %i[ip remote_ip].each do |m| 234 | describe "request.#{m}" do 235 | subject { send(m, env) } 236 | 237 | shared_examples 'it gets the correct ip address from rack' do 238 | it 'works' do 239 | expect(subject[0]).to eq(expected_ip) 240 | if ENV['RACK_ATTACK'] 241 | expect(subject.dig(1, 'rack.attack.throttle_data', 'requests per ip', 242 | :discriminator)).to eq(expected_ip) 243 | end 244 | end 245 | end 246 | 247 | context 'with a cloudflare ip' do 248 | let(:env) { cf_env } 249 | let(:expected_ip) { base_ip } 250 | 251 | it_behaves_like 'it gets the correct ip address from rack' 252 | end 253 | 254 | context 'with a non-cloudflare ip' do 255 | let(:env) { non_cf_env } 256 | let(:expected_ip) { non_cf_ip } 257 | 258 | it_behaves_like 'it gets the correct ip address from rack' 259 | end 260 | 261 | context 'with a cloudflare ip and a local proxy' do 262 | let(:env) { cf_proxy_env } 263 | let(:expected_ip) { base_ip } 264 | 265 | it_behaves_like 'it gets the correct ip address from rack' 266 | end 267 | 268 | context 'works with a non-cloudflare ip and a local proxy' do 269 | let(:env) { non_cf_proxy_env } 270 | let(:expected_ip) { non_cf_ip } 271 | 272 | it_behaves_like 'it gets the correct ip address from rack' 273 | end 274 | 275 | context 'with an invalid ip' do 276 | let(:base_ip) { 'not-an-ip.test,122.175.218.25' } 277 | let(:env) { cf_env } 278 | let(:expected_ip) { '122.175.218.25' } 279 | 280 | it_behaves_like 'it gets the correct ip address from rack' 281 | end 282 | end 283 | 284 | describe "##{m}", type: :controller do 285 | controller do 286 | def index 287 | render status: :ok, json: { ip: request.ip, remote_ip: request.remote_ip } 288 | end 289 | end 290 | 291 | shared_examples 'it gets the correct ip address from rails' do 292 | it 'works' do 293 | request.env.merge! env 294 | get :index 295 | expect(response).to have_http_status(:ok) 296 | expect(JSON[response.body][m.to_s]).to eq(expected_ip) 297 | end 298 | end 299 | 300 | context 'with a cloudflare ip' do 301 | let(:env) { cf_env } 302 | let(:expected_ip) { base_ip } 303 | 304 | it_behaves_like 'it gets the correct ip address from rails' 305 | end 306 | 307 | context 'with a non-cloudflare ip' do 308 | let(:env) { non_cf_env } 309 | let(:expected_ip) { non_cf_ip } 310 | 311 | it_behaves_like 'it gets the correct ip address from rails' 312 | end 313 | 314 | context 'with a cloudflare ip and a local proxy' do 315 | let(:env) { cf_proxy_env } 316 | let(:expected_ip) { base_ip } 317 | 318 | it_behaves_like 'it gets the correct ip address from rails' 319 | end 320 | 321 | context 'with a non-cloudflare ip and a local proxy' do 322 | let(:env) { non_cf_proxy_env } 323 | let(:expected_ip) { non_cf_ip } 324 | 325 | it_behaves_like 'it gets the correct ip address from rails' 326 | end 327 | 328 | context 'with an invalid ip' do 329 | let(:base_ip) { 'not-an-ip.test,122.175.218.25' } 330 | let(:env) { cf_env } 331 | let(:expected_ip) { '122.175.218.25' } 332 | 333 | it_behaves_like 'it gets the correct ip address from rails' 334 | end 335 | end 336 | end 337 | end 338 | end 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require 'bundler/setup' 6 | require 'pry' 7 | 8 | # Only the parts of rails we want to use 9 | require 'action_controller/railtie' 10 | require 'action_view/railtie' 11 | require 'rails/test_unit/railtie' 12 | 13 | # pull in rspec/rails before cloudflare/rails since that'll pull in rails which 14 | # matches the ordering in a rails app 15 | require 'rspec/rails' 16 | require 'webmock/rspec' 17 | 18 | if ENV['RACK_ATTACK'] == 'first' 19 | # pull in rack/attack first to make sure patches work with it 20 | require 'rack/attack' 21 | end 22 | 23 | require 'cloudflare-rails' 24 | 25 | if ENV['RACK_ATTACK'] == 'last' 26 | # pull in rack/attack last to make sure patches work with it 27 | require 'rack/attack' 28 | end 29 | 30 | RSpec.configure do |config| 31 | config.infer_base_class_for_anonymous_controllers = false 32 | end 33 | --------------------------------------------------------------------------------