From d8a159affdf9dfdea6042d6d8a8586b10b860fd7 Mon Sep 17 00:00:00 2001 From: Joel Van Horn Date: Fri, 14 Sep 2018 19:14:24 -0400 Subject: [PATCH] Release v1.0.0 --- .gitignore | 14 + .rspec | 3 + .rubocop.yml | 43 +++ .travis.yml | 5 + Gemfile | 8 + README.md | 144 ++++++++++ Rakefile | 31 ++ bin/console | 14 + bin/setup | 8 + data/ips_v4.txt | 14 + data/ips_v6.txt | 6 + lib/rack/cloudflare.rb | 21 ++ lib/rack/cloudflare/countries.rb | 268 ++++++++++++++++++ lib/rack/cloudflare/headers.rb | 131 +++++++++ lib/rack/cloudflare/ips.rb | 46 +++ .../cloudflare/middleware/access_control.rb | 34 +++ .../cloudflare/middleware/rewrite_headers.rb | 23 ++ lib/rack/cloudflare/version.rb | 7 + rack-cloudflare.gemspec | 31 ++ spec/rack/cloudflare_spec.rb | 168 +++++++++++ spec/spec_helper.rb | 14 + 21 files changed, 1033 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 data/ips_v4.txt create mode 100644 data/ips_v6.txt create mode 100644 lib/rack/cloudflare.rb create mode 100644 lib/rack/cloudflare/countries.rb create mode 100644 lib/rack/cloudflare/headers.rb create mode 100644 lib/rack/cloudflare/ips.rb create mode 100644 lib/rack/cloudflare/middleware/access_control.rb create mode 100644 lib/rack/cloudflare/middleware/rewrite_headers.rb create mode 100644 lib/rack/cloudflare/version.rb create mode 100644 rack-cloudflare.gemspec create mode 100644 spec/rack/cloudflare_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b85b640 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status + +Gemfile.lock +*.gem diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..509f189 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,43 @@ +AllCops: + TargetRubyVersion: 2.5.1 + # Rails: true + # Include: + # - '**/Rakefile' + # - '**/config.ru' + Exclude: + - 'doc/**/*' + - 'tmp/**/*' + - 'bin/**/*' + - 'db/**/*' + - 'test/**/*' + - 'config/**/*' + - 'script/**/*' + - 'vendor/**/*' + - 'spec/**/*' + - !ruby/regexp /old_and_unused\.rb$/ + +# Style/WhileUntilModifier: +# MaxLineLength: 160 + +# Style/IfUnlessModifier: +# MaxLineLength: 160 + +Metrics/LineLength: + Max: 160 + +Metrics/AbcSize: + Max: 120 + +Metrics/MethodLength: + CountComments: false # count full line comments? + Max: 120 + +NumericPredicate: + EnforcedStyle: comparison + +Style/Documentation: + Enabled: false + +Metrics/ModuleLength: + Exclude: + - 'lib/rack/cloudflare/countries.rb' \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..18a30f6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: ruby +rvm: + - 2.5.1 +before_install: gem install bundler -v 1.16.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..be098de --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +# Specify your gem's dependencies in rack-cloudflare.gemspec +gemspec diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbbc9ad --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Rack::Cloudflare + +Deal with Cloudflare features in your Ruby app using Rack middleware. Also provides a Ruby toolkit to deal with Cloudflare in other contexts if you'd like. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'rack-cloudflare' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install rack-cloudflare + +## Usage + +### Whitelist Cloudflare IP addresses + +You can block access to non-Cloudflare networks using `Rack::Cloudflare::Middleware::AccessControl`. + + require 'rack/cloudflare' + + # In config.ru + use Rack::Cloudflare::Middleware::AccessControl + + # In Rails config/application.rb + config.middleware.use Rack::Cloudflare::Middleware::AccessControl + + # Configure custom blocked message (defaults to "Forbidden") + Rack::Cloudflare::Middleware::AccessControl.blocked_message = "You don't belong here..." + + # Fully customize the Rack response (such as making it a redirect) + Rack::Cloudflare::Middleware::AccessControl.blocked_response = lambda do |_env| + [301, { 'Location' => 'https://somewhere.else.xyz' }, ["Redirecting...\n"]] + end + +Alternatively, using [`Rack::Attack`](https://github.com/kickstarter/rack-attack) you can easily add a "safelist" rule. + + Rack::Attack.safelist('Only allow requests through the Cloudflare network') do |request| + Rack::Cloudflare::Headers.trusted?(request.env) + end + +Utilizing the `trusted?` helper method, you can implement a similar check using other middleware. + +See _Toolkits: Detect Cloudflare Requests_ for alternative uses. + +### Rewrite Cloudflare Remote/Client IP address + +You can set `REMOTE_ADDR` to the correct remote IP using `Rack::Cloudflare::Middleware::RewriteHeaders`. + + require 'rack/cloudflare' + + # In config.ru + use Rack::Cloudflare::Middleware::RewriteHeaders + + # In Rails config/application.rb + config.middleware.use Rack::Cloudflare::Middleware::RewriteHeaders + +You can customize whether rewritten headers should be backed up and what names to use. + + # Toggle header backups + Rack::Cloudflare::Headers.backup = false + + # Rename backed up headers (defaults: "ORIGINAL_REMOTE_ADDR", "ORIGINAL_FORWARDED_FOR") + Rack::Cloudflare::Headers.original_remote_addr = 'BACKUP_REMOTE_ADDR' + Rack::Cloudflare::Headers.original_forwarded_for = 'BACKUP_FORWARDED_FOR' + +See _Toolkits: Rewrite Headers_ for alternative uses. + +### Logging + +You can enable logging to see what requests are blocked or headers are rewritten. + + Rack::Cloudflare.logger = Logger.new(STDOUT) + +Log levels used are INFO, DEBUG and WARN. + +## Toolkits + +### Detect Cloudflare Requests + +You can very easily check your HTTP headers to see if the request came from a Cloudflare network. + + # Your headers are in a `Hash` format + # e.g. { 'REMOTE_ADDR' => '0.0.0.0', ... } + # Verifies the remote address + Rack::Cloudflare::Headers.trusted?(headers) + +Note that we can only trust the `REMOTE_ADDR` header to verify a request came from Cloudflare. +The `HTTP_X_FORWARDED_FOR` header can be modified and therefore not trusted. + +Make sure your web server does not modify `REMOTE_ADDR` because it could cause security holes. +Read this article, for example: [Anatomy of an Attack: How I Hacked StackOverflow](https://blog.ircmaxell.com/2012/11/anatomy-of-attack-how-i-hacked.html) + +### Rewrite Headers + +We can easily rewrite `REMOTE_ADDR` and add `HTTP_X_FORWARDED_FOR` based on verifying the request comes from a Cloudflare network. + + # Get a list of headers relevant to Cloudflare (unmodified) + headers = Rack::Cloudflare::Headers.new(headers).target_headers + + # Get a list of headers that will be rewritten (modified) + headers = Rack::Cloudflare::Headers.new(headers).rewritten_headers + + # Get a list of headers relevant to Cloudflare with rewritten values + headers = Rack::Cloudflare::Headers.new(headers).rewritten_target_headers + + # Update original headers with rewritten ones + headers = Rack::Cloudflare::Headers.new(headers).rewrite + +### Up-to-date Cloudflare IP addresses + +Cloudflare provides a [list of IP addresses](https://www.cloudflare.com/ips/) that are important to keep up-to-date. + +A copy of the IPs are kept in [/data](./data/). The list is converted to a `IPAddr` list and is accessible as: + + # Configurable list of IPs + # Defaults to Rack::Cloudflare::IPs::DEFAULTS + Rack::Cloudflare::IPs.list + +The list can be updated to Cloudflare's latest published IP lists in-memory: + + # Fetches Rack::Cloudflare::IPs::V4_URL and Rack::Cloudflare::IPs::V6_URL + Rack::Cloudflare::IPs.refresh! + + # Updates cached list in-memory + Rack::Cloudflare::IPs.list + +## Credits + +Inspired by: + +* https://github.com/tatey/rack-cloudflare +* https://github.com/rikas/cloudflare_localizable + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/joelvh/rack-cloudflare. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cf53798 --- /dev/null +++ b/Rakefile @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' +require 'rubycritic/rake_task' + +RuboCop::RakeTask.new do |task| + task.requires << 'rubocop-rspec' +end + +RubyCritic::RakeTask.new do |task| + # # Name of RubyCritic task. Defaults to :rubycritic. + # task.name = 'something_special' + + # # Glob pattern to match source files. Defaults to FileList['.']. + task.paths = FileList['apps/**/*.rb', 'lib/**/*.rb'] + + # # You can pass all the options here in that are shown by "rubycritic -h" except for + # # "-p / --path" since that is set separately. Defaults to ''. + # task.options = '--mode-ci --format json' + # # task.options = '--no-browser' + + # # Defaults to false + task.verbose = true +end + +RSpec::Core::RakeTask.new(:spec) + +# task default: %w[rubocop:auto_correct rubycritic spec] +task default: %w[rubocop:auto_correct spec] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..cdf9229 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'rack/cloudflare' + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require 'irb' +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/data/ips_v4.txt b/data/ips_v4.txt new file mode 100644 index 0000000..abaecad --- /dev/null +++ b/data/ips_v4.txt @@ -0,0 +1,14 @@ +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +104.16.0.0/12 +108.162.192.0/18 +131.0.72.0/22 +141.101.64.0/18 +162.158.0.0/15 +172.64.0.0/13 +173.245.48.0/20 +188.114.96.0/20 +190.93.240.0/20 +197.234.240.0/22 +198.41.128.0/17 \ No newline at end of file diff --git a/data/ips_v6.txt b/data/ips_v6.txt new file mode 100644 index 0000000..8a1c0ce --- /dev/null +++ b/data/ips_v6.txt @@ -0,0 +1,6 @@ +2400:cb00::/32 +2405:b500::/32 +2606:4700::/32 +2803:f800::/32 +2c0f:f248::/32 +2a06:98c0::/29 \ No newline at end of file diff --git a/lib/rack/cloudflare.rb b/lib/rack/cloudflare.rb new file mode 100644 index 0000000..20cc93b --- /dev/null +++ b/lib/rack/cloudflare.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative 'cloudflare/version' +require_relative 'cloudflare/countries' +require_relative 'cloudflare/ips' +require_relative 'cloudflare/headers' + +require_relative 'cloudflare/middleware/access_control' +require_relative 'cloudflare/middleware/rewrite_headers' + +module Rack + class Cloudflare + class << self + attr_accessor :logger + + %i[info debug warn error].each do |m| + define_method(m) { |*args| logger&.__send__(m, *args) } + end + end + end +end diff --git a/lib/rack/cloudflare/countries.rb b/lib/rack/cloudflare/countries.rb new file mode 100644 index 0000000..791b3c6 --- /dev/null +++ b/lib/rack/cloudflare/countries.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +module Rack + class Cloudflare + module Countries + def self.[](abbr) + LIST.fetch(abbr, UNKNOWN) + end + + DEFAULT = 'XX' + UNKNOWN = 'Unknown' + + LIST = { + DEFAULT => UNKNOWN, + + 'AD' => 'Andorra', + 'AE' => 'United Arab Emirates', + 'AF' => 'Afghanistan', + 'AG' => 'Antigua and Barbuda', + 'AI' => 'Anguilla', + 'AL' => 'Albania', + 'AM' => 'Armenia', + 'AO' => 'Angola', + 'AQ' => 'Antarctica', + 'AR' => 'Argentina', + 'AS' => 'American Samoa', + 'AT' => 'Austria', + 'AU' => 'Australia', + 'AW' => 'Aruba', + 'AX' => 'Aland Islands', + 'AZ' => 'Azerbaijan', + 'BA' => 'Bosnia and Herzegovina', + 'BB' => 'Barbados', + 'BD' => 'Bangladesh', + 'BE' => 'Belgium', + 'BF' => 'Burkina Faso', + 'BG' => 'Bulgaria', + 'BH' => 'Bahrain', + 'BI' => 'Burundi', + 'BJ' => 'Benin', + 'BL' => 'Saint Barthélemy', + 'BM' => 'Bermuda', + 'BN' => 'Brunei', + 'BO' => 'Bolivia', + 'BQ' => 'Caribbean Netherlands', + 'BR' => 'Brazil', + 'BS' => 'The Bahamas', + 'BT' => 'Bhutan', + 'BV' => 'Bouvet Island', + 'BW' => 'Botswana', + 'BY' => 'Belarus', + 'BZ' => 'Belize', + 'CA' => 'Canada', + 'CC' => 'Cocos (Keeling) Islands', + 'CD' => 'Democratic Republic of the Congo', + 'CF' => 'Central African Republic', + 'CG' => 'Republic of the Congo', + 'CH' => 'Switzerland', + 'CI' => "Cote d'Ivoire", + 'CK' => 'Cook Islands', + 'CL' => 'Chile', + 'CM' => 'Cameroon', + 'CN' => 'China', + 'CO' => 'Colombia', + 'CR' => 'Costa Rica', + 'CU' => 'Cuba', + 'CV' => 'Cape Verde', + 'CW' => 'Curaçao', + 'CX' => 'Christmas Island', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DE' => 'Germany', + 'DJ' => 'Djibouti', + 'DK' => 'Denmark', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'DZ' => 'Algeria', + 'EC' => 'Ecuador', + 'EE' => 'Estonia', + 'EG' => 'Egypt', + 'EH' => 'Western Sahara', + 'ER' => 'Eritrea', + 'ES' => 'Spain', + 'ET' => 'Ethiopia', + 'FI' => 'Finland', + 'FJ' => 'Fiji', + 'FK' => 'Falkland Islands', + 'FM' => 'Federated States of Micronesia', + 'FO' => 'Faroe Islands', + 'FR' => 'France', + 'GA' => 'Gabon', + 'GB' => 'United Kingdom', + 'GD' => 'Grenada', + 'GE' => 'Georgia', + 'GF' => 'French Guiana', + 'GG' => 'Guernsey', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GL' => 'Greenland', + 'GM' => 'The Gambia', + 'GN' => 'Guinea', + 'GP' => 'Guadeloupe', + 'GQ' => 'Equatorial Guinea', + 'GR' => 'Greece', + 'GS' => 'South Georgia and the South Sandwich Islands', + 'GT' => 'Guatemala', + 'GU' => 'Guam', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HK' => 'Hong Kong', + 'HM' => 'Heard Island and McDonald Islands', + 'HN' => 'Honduras', + 'HR' => 'Croatia', + 'HT' => 'Haiti', + 'HU' => 'Hungary', + 'ID' => 'Indonesia', + 'IE' => 'Republic of Ireland', + 'IL' => 'Israel', + 'IM' => 'Isle of Man', + 'IN' => 'India', + 'IO' => 'British Indian Ocean Territory', + 'IQ' => 'Iraq', + 'IR' => 'Iran', + 'IS' => 'Iceland', + 'IT' => 'Italy', + 'JE' => 'Jersey', + 'JM' => 'Jamaica', + 'JO' => 'Jordan', + 'JP' => 'Japan', + 'KE' => 'Kenya', + 'KG' => 'Kyrgyzstan', + 'KH' => 'Cambodia', + 'KI' => 'Kiribati', + 'KM' => 'Comoros', + 'KN' => 'Saint Kitts and Nevis', + 'KP' => 'North Korea', + 'KR' => 'South Korea', + 'KW' => 'Kuwait', + 'KY' => 'Cayman Islands', + 'KZ' => 'Kazakhstan', + 'LA' => 'Laos', + 'LB' => 'Lebanon', + 'LC' => 'Saint Lucia', + 'LI' => 'Liechtenstein', + 'LK' => 'Sri Lanka', + 'LR' => 'Liberia', + 'LS' => 'Lesotho', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'LV' => 'Latvia', + 'LY' => 'Libya', + 'MA' => 'Morocco', + 'MC' => 'Monaco', + 'MD' => 'Moldova', + 'ME' => 'Montenegro', + 'MF' => 'Collectivity of Saint Martin', + 'MG' => 'Madagascar', + 'MH' => 'Marshall Islands', + 'MK' => 'Republic of Macedonia', + 'ML' => 'Mali', + 'MM' => 'Myanmar', + 'MN' => 'Mongolia', + 'MO' => 'Macau', + 'MP' => 'Northern Mariana Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MS' => 'Montserrat', + 'MT' => 'Malta', + 'MU' => 'Mauritius', + 'MV' => 'Maldives', + 'MW' => 'Malawi', + 'MX' => 'Mexico', + 'MY' => 'Malaysia', + 'MZ' => 'Mozambique', + 'NA' => 'Namibia', + 'NC' => 'New Caledonia', + 'NE' => 'Niger', + 'NF' => 'Norfolk Island', + 'NG' => 'Nigeria', + 'NI' => 'Nicaragua', + 'NL' => 'Netherlands', + 'NO' => 'Norway', + 'NP' => 'Nepal', + 'NR' => 'Nauru', + 'NU' => 'Niue', + 'NZ' => 'New Zealand', + 'OM' => 'Oman', + 'PA' => 'Panama', + 'PE' => 'Peru', + 'PF' => 'French Polynesia', + 'PG' => 'Papua New Guinea', + 'PH' => 'Philippines', + 'PK' => 'Pakistan', + 'PL' => 'Poland', + 'PM' => 'Saint Pierre and Miquelon', + 'PN' => 'Pitcairn Islands', + 'PR' => 'Puerto Rico', + 'PS' => 'State of Palestine', + 'PT' => 'Portugal', + 'PW' => 'Palau', + 'PY' => 'Paraguay', + 'QA' => 'Qatar', + 'RE' => 'Reunion', + 'RO' => 'Romania', + 'RS' => 'Serbia', + 'RU' => 'Russia', + 'RW' => 'Rwanda', + 'SA' => 'Saudi Arabia', + 'SB' => 'Solomon Islands', + 'SC' => 'Seychelles', + 'SD' => 'Sudan', + 'SE' => 'Sweden', + 'SG' => 'Singapore', + 'SH' => 'Saint Helena, Ascension and Tristan da Cunha', + 'SI' => 'Slovenia', + 'SJ' => 'Svalbard and Jan Mayen', + 'SK' => 'Slovakia', + 'SL' => 'Sierra Leone', + 'SM' => 'San Marino', + 'SN' => 'Senegal', + 'SO' => 'Somalia', + 'SR' => 'Suriname', + 'SS' => 'South Sudan', + 'ST' => 'São Tomé and Príncipe', + 'SV' => 'El Salvador', + 'SX' => 'Sint Maarten', + 'SY' => 'Syria', + 'SZ' => 'Swaziland', + 'TC' => 'Turks and Caicos Islands', + 'TD' => 'Chad', + 'TF' => 'French Southern and Antarctic Lands', + 'TG' => 'Togo', + 'TH' => 'Thailand', + 'TJ' => 'Tajikistan', + 'TK' => 'Tokelau', + 'TL' => 'East Timor', + 'TM' => 'Turkmenistan', + 'TN' => 'Tunisia', + 'TO' => 'Tonga', + 'TR' => 'Turkey', + 'TT' => 'Trinidad and Tobago', + 'TV' => 'Tuvalu', + 'TW' => 'Taiwan', + 'TZ' => 'Tanzania', + 'UA' => 'Ukraine', + 'UG' => 'Uganda', + 'UM' => 'United States Minor Outlying Islands', + 'US' => 'United States', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VA' => 'Vatican City', + 'VC' => 'Saint Vincent and the Grenadines', + 'VE' => 'Venezuela', + 'VG' => 'British Virgin Islands', + 'VI' => 'United States Virgin Islands', + 'VN' => 'Vietnam', + 'VU' => 'Vanuatu', + 'WF' => 'Wallis and Futuna', + 'WS' => 'Samoa', + 'YE' => 'Yemen', + 'YT' => 'Mayotte', + 'ZA' => 'South Africa', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe' + }.freeze + end + end +end diff --git a/lib/rack/cloudflare/headers.rb b/lib/rack/cloudflare/headers.rb new file mode 100644 index 0000000..c3ff1e0 --- /dev/null +++ b/lib/rack/cloudflare/headers.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'json' + +module Rack + class Cloudflare + class Headers + # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- + NAMES = %w[ + HTTP_CF_IPCOUNTRY + HTTP_CF_CONNECTING_IP + HTTP_CF_RAY + HTTP_CF_VISITOR + ].freeze + + STANDARD = %w[ + HTTP_X_FORWARDED_FOR + HTTP_X_FORWARDED_PROTO + REMOTE_ADDR + ].freeze + + ALL = (NAMES + STANDARD).freeze + + # Create constants for each header + ALL.map { |h| const_set h, h.to_s.freeze }.freeze + + class << self + attr_accessor :backup, :original_remote_addr, :original_forwarded_for + + def trusted?(headers) + Headers.new(headers).trusted? + end + end + + self.backup = true + self.original_remote_addr = 'ORIGINAL_REMOTE_ADDR' + self.original_forwarded_for = 'ORIGINAL_FORWARDED_FOR' + + def initialize(headers) + @headers = headers + end + + # "Cf-Ipcountry: US" + def ip_country + @headers.fetch(HTTP_CF_IPCOUNTRY, 'XX') + end + + # "CF-Connecting-IP: A.B.C.D" + def connecting_ip + @connecting_ip ||= IPs.parse(@headers[HTTP_CF_CONNECTING_IP]).first + end + + # "X-Forwarded-For: A.B.C.D" + # "X-Forwarded-For: A.B.C.D[,X.X.X.X,Y.Y.Y.Y,]" + def forwarded_for + @forwarded_for ||= IPs.parse(@headers[HTTP_X_FORWARDED_FOR]) + end + + # "X-Forwarded-Proto: https" + def forwarded_proto + @headers[HTTP_X_FORWARDED_PROTO] + end + + # "Cf-Ray: 230b030023ae2822-SJC" + def ray + @headers[HTTP_CF_RAY] + end + + # "Cf-Visitor: { \"scheme\":\"https\"}" + def visitor + return unless has?(HTTP_CF_VISITOR) + JSON.parse @headers[HTTP_CF_VISITOR] + end + + def remote_addr + @remote_addr ||= IPs.parse(@headers[REMOTE_ADDR]).first + end + + # Indicates if the headers passed through Cloudflare + def trusted? + IPs.list.any? { |range| range.include? remote_addr } + end + + def backup_headers + return {} unless Headers.backup + + {}.tap do |headers| + headers[Headers.original_remote_addr] = @headers[REMOTE_ADDR] + headers[Headers.original_forwarded_for] = @headers[HTTP_X_FORWARDED_FOR] + end + end + + def rewritten_headers + # Only rewrites headers if it's a Cloudflare request + return {} unless trusted? + + {}.tap do |headers| + headers.merge! backup_headers + + # Overwrite the original remote IP based on + # Cloudflare's specified original remote IP + headers[REMOTE_ADDR] = connecting_ip.to_s if connecting_ip + + # Add HTTP_X_FORWARDED_FOR if it wasn't present. + # Cloudflare will already have modified the header if + # it was present in the original request. + # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- + headers[HTTP_X_FORWARDED_FOR] = "#{connecting_ip}, #{remote_addr}" if forwarded_for.none? + end + end + + # Headers that relate to Cloudflare + # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- + def target_headers + @headers.select { |k, _| ALL.include? k } + end + + def rewritten_target_headers + target_headers.merge(rewritten_headers) + end + + def rewrite + @headers.merge(rewritten_headers) + end + + def has?(header) + @headers.key?(header) + end + end + end +end diff --git a/lib/rack/cloudflare/ips.rb b/lib/rack/cloudflare/ips.rb new file mode 100644 index 0000000..f1074cb --- /dev/null +++ b/lib/rack/cloudflare/ips.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'net/http' + +module Rack + class Cloudflare + module IPs + # See: https://www.cloudflare.com/ips/ + V4_URL = 'https://www.cloudflare.com/ips-v4' + V6_URL = 'https://www.cloudflare.com/ips-v6' + + class << self + # List of IPs to reference + attr_accessor :list + + # Refresh list of IPs in case local copy is outdated + def refresh! + self.list = fetch(V4_URL) + fetch(V6_URL) + end + + def fetch(url) + parse Net::HTTP.get(URI(url)) + end + + def read(filename) + parse File.read(filename) + end + + def parse(string) + return [] if string.to_s.strip.empty? + string.split(/[,\s]+/).map { |ip| IPAddr.new(ip.strip) } + end + end + + V4 = read("#{__dir__}/../../../data/ips_v4.txt") + V6 = read("#{__dir__}/../../../data/ips_v6.txt") + + DEFAULTS = V4 + V6 + + ### Configure + + self.list = DEFAULTS + end + end +end diff --git a/lib/rack/cloudflare/middleware/access_control.rb b/lib/rack/cloudflare/middleware/access_control.rb new file mode 100644 index 0000000..cc404db --- /dev/null +++ b/lib/rack/cloudflare/middleware/access_control.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Rack + class Cloudflare + module Middleware + class AccessControl + class << self + attr_accessor :blocked_response, :blocked_message + end + + self.blocked_message = 'Forbidden' + self.blocked_response = lambda do |_env| + [403, { 'Content-Type' => 'text/plain' }, ["#{blocked_message.strip}\n"]] + end + + def initialize(app) + @app = app + end + + def call(env) + headers = Headers.new(env) + + if headers.trusted? + Cloudflare.info "[#{self.class.name}] Trusted Network (REMOTE_ADDR): #{headers.target_headers}" + @app.call(env) + else + Cloudflare.warn "[#{self.class.name}] Untrusted Network (REMOTE_ADDR): #{headers.target_headers}" + AccessControl.blocked_response.call(env) + end + end + end + end + end +end diff --git a/lib/rack/cloudflare/middleware/rewrite_headers.rb b/lib/rack/cloudflare/middleware/rewrite_headers.rb new file mode 100644 index 0000000..09fc8d6 --- /dev/null +++ b/lib/rack/cloudflare/middleware/rewrite_headers.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Rack + class Cloudflare + module Middleware + class RewriteHeaders + def initialize(app) + @app = app + end + + def call(env) + headers = Headers.new(env) + + Cloudflare.warn "[#{self.class.name}] Untrusted Network (REMOTE_ADDR): #{headers.target_headers}" unless headers.trusted? + Cloudflare.debug "[#{self.class.name}] Target Headers: #{headers.target_headers}" + Cloudflare.debug "[#{self.class.name}] Rewritten Headers: #{headers.rewritten_target_headers}" + + @app.call(headers.rewrite) + end + end + end + end +end diff --git a/lib/rack/cloudflare/version.rb b/lib/rack/cloudflare/version.rb new file mode 100644 index 0000000..1c55489 --- /dev/null +++ b/lib/rack/cloudflare/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Rack + class Cloudflare + VERSION = '1.0.0' + end +end diff --git a/rack-cloudflare.gemspec b/rack-cloudflare.gemspec new file mode 100644 index 0000000..76071aa --- /dev/null +++ b/rack-cloudflare.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'rack/cloudflare/version' + +Gem::Specification.new do |spec| + spec.name = 'rack-cloudflare' + spec.version = Rack::Cloudflare::VERSION + spec.authors = ['Joel Van Horn'] + spec.email = ['joel@joelvanhorn.com'] + + spec.summary = 'Deal with Cloudflare features in Rack-based apps.' + spec.description = 'Deal with Cloudflare features in Rack-based apps.' + spec.homepage = 'https://github.com/joelvh/rack-cloudflare' + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_development_dependency 'bundler', '~> 1.16' + spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'rubycritic' +end diff --git a/spec/rack/cloudflare_spec.rb b/spec/rack/cloudflare_spec.rb new file mode 100644 index 0000000..646f15c --- /dev/null +++ b/spec/rack/cloudflare_spec.rb @@ -0,0 +1,168 @@ +RSpec.describe Rack::Cloudflare do + let!(:default_response) { Rack::Cloudflare::Middleware::AccessControl.blocked_response } + + before(:each) do + Rack::Cloudflare::Headers.backup = true + Rack::Cloudflare::Headers.original_remote_addr = 'ORIGINAL_REMOTE_ADDR' + Rack::Cloudflare::Headers.original_forwarded_for = 'ORIGINAL_FORWARDED_FOR' + + Rack::Cloudflare::Middleware::AccessControl.blocked_message = 'Forbidden' + Rack::Cloudflare::Middleware::AccessControl.blocked_response = default_response + end + + it 'blocks access for non-Cloudflare networks' do + env = { 'REMOTE_ADDR' => '127.0.0.1' } + middleware = Rack::Cloudflare::Middleware::AccessControl.new(->(*) { 'success' }) + + expect(middleware.call(env)).to eq([403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]]) + end + + it 'grants access for Cloudflare networks' do + env = { 'REMOTE_ADDR' => '103.21.244.1' } + middleware = Rack::Cloudflare::Middleware::AccessControl.new(->(*) { 'success' }) + + expect(middleware.call(env)).to eq('success') + end + + it 'forbids access by default' do + env = { 'REMOTE_ADDR' => '103.21.244.1' } + middleware = default_response + + expect(middleware.call(env)).to eq([403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]]) + end + + it 'forbids with custom message' do + Rack::Cloudflare::Middleware::AccessControl.blocked_message = 'Go away' + + env = { 'REMOTE_ADDR' => '127.0.0.1' } + middleware = Rack::Cloudflare::Middleware::AccessControl.new(->(_e) { 'success' }) + + expect(middleware.call(env)).to eq([403, { 'Content-Type' => 'text/plain' }, ["Go away\n"]]) + end + + it 'forbids with custom response' do + Rack::Cloudflare::Middleware::AccessControl.blocked_response = lambda do |_env| + [301, { 'Location' => 'https://somewhere.else.xyz' }, ["Bye bye\n"]] + end + + env = { 'REMOTE_ADDR' => '127.0.0.1' } + middleware = Rack::Cloudflare::Middleware::AccessControl.new(->(_e) { 'success' }) + + expect(middleware.call(env)).to eq([301, { 'Location' => 'https://somewhere.else.xyz' }, ["Bye bye\n"]]) + end + + it 'rewrites REMOTE_ADDR for trusted headers' do + env = { + 'REMOTE_ADDR' => '103.21.244.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'ORIGINAL_FORWARDED_FOR' => nil, + 'ORIGINAL_REMOTE_ADDR' => '103.21.244.1', + 'REMOTE_ADDR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + ) + end + + it "doesn't rewrite REMOTE_ADDR headers for untrusted headers" do + env = { + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4' + ) + end + + it "doesn't rewrite HTTP_X_FORWARDED_FOR headers" do + env = { + 'REMOTE_ADDR' => '103.21.244.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 0.0.0.0, 103.21.244.1' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'ORIGINAL_FORWARDED_FOR' => '1.2.3.4, 0.0.0.0, 103.21.244.1', + 'ORIGINAL_REMOTE_ADDR' => '103.21.244.1', + 'REMOTE_ADDR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 0.0.0.0, 103.21.244.1' + ) + end + + it 'backs up headers when trusted network' do + env = { + 'REMOTE_ADDR' => '103.21.244.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'ORIGINAL_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1', + 'ORIGINAL_REMOTE_ADDR' => '103.21.244.1', + 'REMOTE_ADDR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + ) + end + + it "doesn't back up headers when untrusted network" do + env = { + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + ) + end + + it "doesn't backup headers when backups disabled" do + Rack::Cloudflare::Headers.backup = false + + env = { + 'REMOTE_ADDR' => '103.21.244.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'REMOTE_ADDR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + ) + end + + it 'uses custom backup header names' do + Rack::Cloudflare::Headers.original_remote_addr = 'BACKUP_REMOTE_ADDR' + Rack::Cloudflare::Headers.original_forwarded_for = 'BACKUP_FORWARDED_FOR' + + env = { + 'REMOTE_ADDR' => '103.21.244.1', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + } + middleware = Rack::Cloudflare::Middleware::RewriteHeaders.new(->(e) { e }) + + expect(middleware.call(env)).to eq( + 'BACKUP_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1', + 'BACKUP_REMOTE_ADDR' => '103.21.244.1', + 'REMOTE_ADDR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 103.21.244.1' + ) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..224162e --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,14 @@ +require 'bundler/setup' +require 'rack/cloudflare' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end