rack-cloudflare 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d361e00dd386a7ee04f14083033478613234cf3595d3fcd2305293dda78f43d2
4
- data.tar.gz: 2d51e3ee954d38f99db080f28d82206a6d3156b1f8de7f1a4dd8e44328979c16
3
+ metadata.gz: 6c8a5b0439022d396fd56721e79b97a96c4cf830b0e37c27a4a9f6f2db2f8bd0
4
+ data.tar.gz: d3866b5cfcce96211b25ae277b5e5280f3f38e0eb5f78d291c23d2a08cf84715
5
5
  SHA512:
6
- metadata.gz: '0318856bf3ad9182b7a22de5a5cb14a8b79ae55c7cb40c68329ba044820a1dcd69b3103ae47b6f4ac727a0f90e405d810a0d61c8b523e41a0b74170791cc300d'
7
- data.tar.gz: 3d871d3ad152afed37a1ca788602e570f601e25d301d73a9c141cfc12464988b080972a935e4f51b5620c6ea710a471f9f57de975285768980b24e56c5d5df6e
6
+ metadata.gz: 8ea482f40489690a5f368f0e21a9aad7fbc59dc76517b351b635ff53064ac8a1339ecc4c3129047967d5bdbafa259aa11a4b79314ea52ebe78b63d3bf031e9f4
7
+ data.tar.gz: 40709bddc477ccd842dd50318827c4af50bbc7cf5a0b8ee58304e0e0d0d736b5fdd04b8b6f81692eb45f82b9a04492c4cb63001e6089490ab8e1cb8ee217b2a4
data/.gitignore CHANGED
@@ -9,3 +9,5 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+
13
+ Gemfile.lock
data/.rubocop.yml ADDED
@@ -0,0 +1,43 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.5.1
3
+ # Rails: true
4
+ # Include:
5
+ # - '**/Rakefile'
6
+ # - '**/config.ru'
7
+ Exclude:
8
+ - 'doc/**/*'
9
+ - 'tmp/**/*'
10
+ - 'bin/**/*'
11
+ - 'db/**/*'
12
+ - 'test/**/*'
13
+ - 'config/**/*'
14
+ - 'script/**/*'
15
+ - 'vendor/**/*'
16
+ - 'spec/**/*'
17
+ - !ruby/regexp /old_and_unused\.rb$/
18
+
19
+ # Style/WhileUntilModifier:
20
+ # MaxLineLength: 160
21
+
22
+ # Style/IfUnlessModifier:
23
+ # MaxLineLength: 160
24
+
25
+ Metrics/LineLength:
26
+ Max: 160
27
+
28
+ Metrics/AbcSize:
29
+ Max: 120
30
+
31
+ Metrics/MethodLength:
32
+ CountComments: false # count full line comments?
33
+ Max: 120
34
+
35
+ NumericPredicate:
36
+ EnforcedStyle: comparison
37
+
38
+ Style/Documentation:
39
+ Enabled: false
40
+
41
+ Metrics/ModuleLength:
42
+ Exclude:
43
+ - 'lib/rack/cloudflare/countries.rb'
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Rack::Cloudflare
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rack/cloudflare`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ 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.
6
4
 
7
5
  ## Installation
8
6
 
@@ -22,13 +20,124 @@ Or install it yourself as:
22
20
 
23
21
  ## Usage
24
22
 
25
- TODO: Write usage instructions here
23
+ ### Whitelist Cloudflare IP addresses
24
+
25
+ You can block access to non-Cloudflare networks using `Rack::Cloudflare::Middleware::AccessControl`.
26
+
27
+ require 'rack/cloudflare'
28
+
29
+ # In config.ru
30
+ use Rack::Cloudflare::Middleware::AccessControl
31
+
32
+ # In Rails config/application.rb
33
+ config.middleware.use Rack::Cloudflare::Middleware::AccessControl
34
+
35
+ # Configure custom blocked message (defaults to "Forbidden")
36
+ Rack::Cloudflare::Middleware::AccessControl.blocked_message = "You don't belong here..."
37
+
38
+ # Fully customize the Rack response (such as making it a redirect)
39
+ Rack::Cloudflare::Middleware::AccessControl.blocked_response = lambda do |_env|
40
+ [301, { 'Location' => 'https://somewhere.else.xyz' }, ["Redirecting...\n"]]
41
+ end
42
+
43
+ Alternatively, using [`Rack::Attack`](https://github.com/kickstarter/rack-attack) you can easily add a "safelist" rule.
44
+
45
+ Rack::Attack.safelist('Only allow requests through the Cloudflare network') do |request|
46
+ Rack::Cloudflare::Headers.trusted?(request.env)
47
+ end
48
+
49
+ Utilizing the `trusted?` helper method, you can implement a similar check using other middleware.
50
+
51
+ See _Toolkits: Detect Cloudflare Requests_ for alternative uses.
52
+
53
+ ### Rewrite Cloudflare Remote/Client IP address
54
+
55
+ You can set `REMOTE_ADDR` to the correct remote IP using `Rack::Cloudflare::Middleware::RewriteHeaders`.
56
+
57
+ require 'rack/cloudflare'
58
+
59
+ # In config.ru
60
+ use Rack::Cloudflare::Middleware::RewriteHeaders
61
+
62
+ # In Rails config/application.rb
63
+ config.middleware.use Rack::Cloudflare::Middleware::RewriteHeaders
64
+
65
+ You can customize whether rewritten headers should be backed up and what names to use.
66
+
67
+ # Toggle header backups
68
+ Rack::Cloudflare::Headers.backup = false
69
+
70
+ # Rename backed up headers (defaults: "ORIGINAL_REMOTE_ADDR", "ORIGINAL_FORWARDED_FOR")
71
+ Rack::Cloudflare::Headers.original_remote_addr = 'BACKUP_REMOTE_ADDR'
72
+ Rack::Cloudflare::Headers.original_forwarded_for = 'BACKUP_FORWARDED_FOR'
73
+
74
+ See _Toolkits: Rewrite Headers_ for alternative uses.
75
+
76
+ ### Logging
77
+
78
+ You can enable logging to see what requests are blocked or headers are rewritten.
79
+
80
+ Rack::Cloudflare.logger = Logger.new(STDOUT)
81
+
82
+ Log levels used are INFO, DEBUG and WARN.
83
+
84
+ ## Toolkits
85
+
86
+ ### Detect Cloudflare Requests
87
+
88
+ You can very easily check your HTTP headers to see if the request came from a Cloudflare network.
89
+
90
+ # Your headers are in a `Hash` format
91
+ # e.g. { 'REMOTE_ADDR' => '0.0.0.0', ... }
92
+ # Verifies the remote address
93
+ Rack::Cloudflare::Headers.trusted?(headers)
94
+
95
+ Note that we can only trust the `REMOTE_ADDR` header to verify a request came from Cloudflare.
96
+ The `HTTP_X_FORWARDED_FOR` header can be modified and therefore not trusted.
97
+
98
+ Make sure your web server does not modify `REMOTE_ADDR` because it could cause security holes.
99
+ 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)
100
+
101
+ ### Rewrite Headers
102
+
103
+ We can easily rewrite `REMOTE_ADDR` and add `HTTP_X_FORWARDED_FOR` based on verifying the request comes from a Cloudflare network.
104
+
105
+ # Get a list of headers relevant to Cloudflare (unmodified)
106
+ headers = Rack::Cloudflare::Headers.new(headers).target_headers
107
+
108
+ # Get a list of headers that will be rewritten (modified)
109
+ headers = Rack::Cloudflare::Headers.new(headers).rewritten_headers
110
+
111
+ # Get a list of headers relevant to Cloudflare with rewritten values
112
+ headers = Rack::Cloudflare::Headers.new(headers).rewritten_target_headers
113
+
114
+ # Update original headers with rewritten ones
115
+ headers = Rack::Cloudflare::Headers.new(headers).rewrite
116
+
117
+ ### Up-to-date Cloudflare IP addresses
118
+
119
+ Cloudflare provides a [list of IP addresses](https://www.cloudflare.com/ips/) that are important to keep up-to-date.
120
+
121
+ A copy of the IPs are kept in [/data](./data/). The list is converted to a `IPAddr` list and is accessible as:
122
+
123
+ # Configurable list of IPs
124
+ # Defaults to Rack::Cloudflare::IPs::DEFAULTS
125
+ Rack::Cloudflare::IPs.list
126
+
127
+ The list can be updated to Cloudflare's latest published IP lists in-memory:
128
+
129
+ # Fetches Rack::Cloudflare::IPs::V4_URL and Rack::Cloudflare::IPs::V6_URL
130
+ Rack::Cloudflare::IPs.refresh!
131
+
132
+ # Updates cached list in-memory
133
+ Rack::Cloudflare::IPs.list
26
134
 
27
- ## Development
135
+ ## Credits
28
136
 
29
- 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.
137
+ Inspired by:
30
138
 
31
- 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).
139
+ * https://github.com/tatey/rack-cloudflare
140
+ * https://github.com/rikas/cloudflare_localizable
32
141
 
33
142
  ## Contributing
34
143
 
data/Rakefile CHANGED
@@ -1,6 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+ require 'rubycritic/rake_task'
7
+
8
+ RuboCop::RakeTask.new do |task|
9
+ task.requires << 'rubocop-rspec'
10
+ end
11
+
12
+ RubyCritic::RakeTask.new do |task|
13
+ # # Name of RubyCritic task. Defaults to :rubycritic.
14
+ # task.name = 'something_special'
15
+
16
+ # # Glob pattern to match source files. Defaults to FileList['.'].
17
+ task.paths = FileList['apps/**/*.rb', 'lib/**/*.rb']
18
+
19
+ # # You can pass all the options here in that are shown by "rubycritic -h" except for
20
+ # # "-p / --path" since that is set separately. Defaults to ''.
21
+ # task.options = '--mode-ci --format json'
22
+ # # task.options = '--no-browser'
23
+
24
+ # # Defaults to false
25
+ task.verbose = true
26
+ end
3
27
 
4
28
  RSpec::Core::RakeTask.new(:spec)
5
29
 
6
- task default: :spec
30
+ # task default: %w[rubocop:auto_correct rubycritic spec]
31
+ task default: %w[rubocop:auto_correct spec]
data/data/ips_v4.txt ADDED
@@ -0,0 +1,14 @@
1
+ 103.21.244.0/22
2
+ 103.22.200.0/22
3
+ 103.31.4.0/22
4
+ 104.16.0.0/12
5
+ 108.162.192.0/18
6
+ 131.0.72.0/22
7
+ 141.101.64.0/18
8
+ 162.158.0.0/15
9
+ 172.64.0.0/13
10
+ 173.245.48.0/20
11
+ 188.114.96.0/20
12
+ 190.93.240.0/20
13
+ 197.234.240.0/22
14
+ 198.41.128.0/17
data/data/ips_v6.txt ADDED
@@ -0,0 +1,6 @@
1
+ 2400:cb00::/32
2
+ 2405:b500::/32
3
+ 2606:4700::/32
4
+ 2803:f800::/32
5
+ 2c0f:f248::/32
6
+ 2a06:98c0::/29
@@ -1,8 +1,21 @@
1
- require 'rack/cloudflare/version'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cloudflare/version'
4
+ require_relative 'cloudflare/countries'
5
+ require_relative 'cloudflare/ips'
6
+ require_relative 'cloudflare/headers'
7
+
8
+ require_relative 'cloudflare/middleware/access_control'
9
+ require_relative 'cloudflare/middleware/rewrite_headers'
2
10
 
3
11
  module Rack
4
- # Documentation goes here...
5
- module Cloudflare
6
- # Your code goes here...
12
+ class Cloudflare
13
+ class << self
14
+ attr_accessor :logger
15
+
16
+ %i[info debug warn error].each do |m|
17
+ define_method(m) { |*args| logger&.__send__(m, *args) }
18
+ end
19
+ end
7
20
  end
8
21
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Cloudflare
5
+ module Countries
6
+ def self.[](abbr)
7
+ LIST.fetch(abbr, UNKNOWN)
8
+ end
9
+
10
+ DEFAULT = 'XX'
11
+ UNKNOWN = 'Unknown'
12
+
13
+ LIST = {
14
+ DEFAULT => UNKNOWN,
15
+
16
+ 'AD' => 'Andorra',
17
+ 'AE' => 'United Arab Emirates',
18
+ 'AF' => 'Afghanistan',
19
+ 'AG' => 'Antigua and Barbuda',
20
+ 'AI' => 'Anguilla',
21
+ 'AL' => 'Albania',
22
+ 'AM' => 'Armenia',
23
+ 'AO' => 'Angola',
24
+ 'AQ' => 'Antarctica',
25
+ 'AR' => 'Argentina',
26
+ 'AS' => 'American Samoa',
27
+ 'AT' => 'Austria',
28
+ 'AU' => 'Australia',
29
+ 'AW' => 'Aruba',
30
+ 'AX' => 'Aland Islands',
31
+ 'AZ' => 'Azerbaijan',
32
+ 'BA' => 'Bosnia and Herzegovina',
33
+ 'BB' => 'Barbados',
34
+ 'BD' => 'Bangladesh',
35
+ 'BE' => 'Belgium',
36
+ 'BF' => 'Burkina Faso',
37
+ 'BG' => 'Bulgaria',
38
+ 'BH' => 'Bahrain',
39
+ 'BI' => 'Burundi',
40
+ 'BJ' => 'Benin',
41
+ 'BL' => 'Saint Barthélemy',
42
+ 'BM' => 'Bermuda',
43
+ 'BN' => 'Brunei',
44
+ 'BO' => 'Bolivia',
45
+ 'BQ' => 'Caribbean Netherlands',
46
+ 'BR' => 'Brazil',
47
+ 'BS' => 'The Bahamas',
48
+ 'BT' => 'Bhutan',
49
+ 'BV' => 'Bouvet Island',
50
+ 'BW' => 'Botswana',
51
+ 'BY' => 'Belarus',
52
+ 'BZ' => 'Belize',
53
+ 'CA' => 'Canada',
54
+ 'CC' => 'Cocos (Keeling) Islands',
55
+ 'CD' => 'Democratic Republic of the Congo',
56
+ 'CF' => 'Central African Republic',
57
+ 'CG' => 'Republic of the Congo',
58
+ 'CH' => 'Switzerland',
59
+ 'CI' => "Cote d'Ivoire",
60
+ 'CK' => 'Cook Islands',
61
+ 'CL' => 'Chile',
62
+ 'CM' => 'Cameroon',
63
+ 'CN' => 'China',
64
+ 'CO' => 'Colombia',
65
+ 'CR' => 'Costa Rica',
66
+ 'CU' => 'Cuba',
67
+ 'CV' => 'Cape Verde',
68
+ 'CW' => 'Curaçao',
69
+ 'CX' => 'Christmas Island',
70
+ 'CY' => 'Cyprus',
71
+ 'CZ' => 'Czech Republic',
72
+ 'DE' => 'Germany',
73
+ 'DJ' => 'Djibouti',
74
+ 'DK' => 'Denmark',
75
+ 'DM' => 'Dominica',
76
+ 'DO' => 'Dominican Republic',
77
+ 'DZ' => 'Algeria',
78
+ 'EC' => 'Ecuador',
79
+ 'EE' => 'Estonia',
80
+ 'EG' => 'Egypt',
81
+ 'EH' => 'Western Sahara',
82
+ 'ER' => 'Eritrea',
83
+ 'ES' => 'Spain',
84
+ 'ET' => 'Ethiopia',
85
+ 'FI' => 'Finland',
86
+ 'FJ' => 'Fiji',
87
+ 'FK' => 'Falkland Islands',
88
+ 'FM' => 'Federated States of Micronesia',
89
+ 'FO' => 'Faroe Islands',
90
+ 'FR' => 'France',
91
+ 'GA' => 'Gabon',
92
+ 'GB' => 'United Kingdom',
93
+ 'GD' => 'Grenada',
94
+ 'GE' => 'Georgia',
95
+ 'GF' => 'French Guiana',
96
+ 'GG' => 'Guernsey',
97
+ 'GH' => 'Ghana',
98
+ 'GI' => 'Gibraltar',
99
+ 'GL' => 'Greenland',
100
+ 'GM' => 'The Gambia',
101
+ 'GN' => 'Guinea',
102
+ 'GP' => 'Guadeloupe',
103
+ 'GQ' => 'Equatorial Guinea',
104
+ 'GR' => 'Greece',
105
+ 'GS' => 'South Georgia and the South Sandwich Islands',
106
+ 'GT' => 'Guatemala',
107
+ 'GU' => 'Guam',
108
+ 'GW' => 'Guinea-Bissau',
109
+ 'GY' => 'Guyana',
110
+ 'HK' => 'Hong Kong',
111
+ 'HM' => 'Heard Island and McDonald Islands',
112
+ 'HN' => 'Honduras',
113
+ 'HR' => 'Croatia',
114
+ 'HT' => 'Haiti',
115
+ 'HU' => 'Hungary',
116
+ 'ID' => 'Indonesia',
117
+ 'IE' => 'Republic of Ireland',
118
+ 'IL' => 'Israel',
119
+ 'IM' => 'Isle of Man',
120
+ 'IN' => 'India',
121
+ 'IO' => 'British Indian Ocean Territory',
122
+ 'IQ' => 'Iraq',
123
+ 'IR' => 'Iran',
124
+ 'IS' => 'Iceland',
125
+ 'IT' => 'Italy',
126
+ 'JE' => 'Jersey',
127
+ 'JM' => 'Jamaica',
128
+ 'JO' => 'Jordan',
129
+ 'JP' => 'Japan',
130
+ 'KE' => 'Kenya',
131
+ 'KG' => 'Kyrgyzstan',
132
+ 'KH' => 'Cambodia',
133
+ 'KI' => 'Kiribati',
134
+ 'KM' => 'Comoros',
135
+ 'KN' => 'Saint Kitts and Nevis',
136
+ 'KP' => 'North Korea',
137
+ 'KR' => 'South Korea',
138
+ 'KW' => 'Kuwait',
139
+ 'KY' => 'Cayman Islands',
140
+ 'KZ' => 'Kazakhstan',
141
+ 'LA' => 'Laos',
142
+ 'LB' => 'Lebanon',
143
+ 'LC' => 'Saint Lucia',
144
+ 'LI' => 'Liechtenstein',
145
+ 'LK' => 'Sri Lanka',
146
+ 'LR' => 'Liberia',
147
+ 'LS' => 'Lesotho',
148
+ 'LT' => 'Lithuania',
149
+ 'LU' => 'Luxembourg',
150
+ 'LV' => 'Latvia',
151
+ 'LY' => 'Libya',
152
+ 'MA' => 'Morocco',
153
+ 'MC' => 'Monaco',
154
+ 'MD' => 'Moldova',
155
+ 'ME' => 'Montenegro',
156
+ 'MF' => 'Collectivity of Saint Martin',
157
+ 'MG' => 'Madagascar',
158
+ 'MH' => 'Marshall Islands',
159
+ 'MK' => 'Republic of Macedonia',
160
+ 'ML' => 'Mali',
161
+ 'MM' => 'Myanmar',
162
+ 'MN' => 'Mongolia',
163
+ 'MO' => 'Macau',
164
+ 'MP' => 'Northern Mariana Islands',
165
+ 'MQ' => 'Martinique',
166
+ 'MR' => 'Mauritania',
167
+ 'MS' => 'Montserrat',
168
+ 'MT' => 'Malta',
169
+ 'MU' => 'Mauritius',
170
+ 'MV' => 'Maldives',
171
+ 'MW' => 'Malawi',
172
+ 'MX' => 'Mexico',
173
+ 'MY' => 'Malaysia',
174
+ 'MZ' => 'Mozambique',
175
+ 'NA' => 'Namibia',
176
+ 'NC' => 'New Caledonia',
177
+ 'NE' => 'Niger',
178
+ 'NF' => 'Norfolk Island',
179
+ 'NG' => 'Nigeria',
180
+ 'NI' => 'Nicaragua',
181
+ 'NL' => 'Netherlands',
182
+ 'NO' => 'Norway',
183
+ 'NP' => 'Nepal',
184
+ 'NR' => 'Nauru',
185
+ 'NU' => 'Niue',
186
+ 'NZ' => 'New Zealand',
187
+ 'OM' => 'Oman',
188
+ 'PA' => 'Panama',
189
+ 'PE' => 'Peru',
190
+ 'PF' => 'French Polynesia',
191
+ 'PG' => 'Papua New Guinea',
192
+ 'PH' => 'Philippines',
193
+ 'PK' => 'Pakistan',
194
+ 'PL' => 'Poland',
195
+ 'PM' => 'Saint Pierre and Miquelon',
196
+ 'PN' => 'Pitcairn Islands',
197
+ 'PR' => 'Puerto Rico',
198
+ 'PS' => 'State of Palestine',
199
+ 'PT' => 'Portugal',
200
+ 'PW' => 'Palau',
201
+ 'PY' => 'Paraguay',
202
+ 'QA' => 'Qatar',
203
+ 'RE' => 'Reunion',
204
+ 'RO' => 'Romania',
205
+ 'RS' => 'Serbia',
206
+ 'RU' => 'Russia',
207
+ 'RW' => 'Rwanda',
208
+ 'SA' => 'Saudi Arabia',
209
+ 'SB' => 'Solomon Islands',
210
+ 'SC' => 'Seychelles',
211
+ 'SD' => 'Sudan',
212
+ 'SE' => 'Sweden',
213
+ 'SG' => 'Singapore',
214
+ 'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
215
+ 'SI' => 'Slovenia',
216
+ 'SJ' => 'Svalbard and Jan Mayen',
217
+ 'SK' => 'Slovakia',
218
+ 'SL' => 'Sierra Leone',
219
+ 'SM' => 'San Marino',
220
+ 'SN' => 'Senegal',
221
+ 'SO' => 'Somalia',
222
+ 'SR' => 'Suriname',
223
+ 'SS' => 'South Sudan',
224
+ 'ST' => 'São Tomé and Príncipe',
225
+ 'SV' => 'El Salvador',
226
+ 'SX' => 'Sint Maarten',
227
+ 'SY' => 'Syria',
228
+ 'SZ' => 'Swaziland',
229
+ 'TC' => 'Turks and Caicos Islands',
230
+ 'TD' => 'Chad',
231
+ 'TF' => 'French Southern and Antarctic Lands',
232
+ 'TG' => 'Togo',
233
+ 'TH' => 'Thailand',
234
+ 'TJ' => 'Tajikistan',
235
+ 'TK' => 'Tokelau',
236
+ 'TL' => 'East Timor',
237
+ 'TM' => 'Turkmenistan',
238
+ 'TN' => 'Tunisia',
239
+ 'TO' => 'Tonga',
240
+ 'TR' => 'Turkey',
241
+ 'TT' => 'Trinidad and Tobago',
242
+ 'TV' => 'Tuvalu',
243
+ 'TW' => 'Taiwan',
244
+ 'TZ' => 'Tanzania',
245
+ 'UA' => 'Ukraine',
246
+ 'UG' => 'Uganda',
247
+ 'UM' => 'United States Minor Outlying Islands',
248
+ 'US' => 'United States',
249
+ 'UY' => 'Uruguay',
250
+ 'UZ' => 'Uzbekistan',
251
+ 'VA' => 'Vatican City',
252
+ 'VC' => 'Saint Vincent and the Grenadines',
253
+ 'VE' => 'Venezuela',
254
+ 'VG' => 'British Virgin Islands',
255
+ 'VI' => 'United States Virgin Islands',
256
+ 'VN' => 'Vietnam',
257
+ 'VU' => 'Vanuatu',
258
+ 'WF' => 'Wallis and Futuna',
259
+ 'WS' => 'Samoa',
260
+ 'YE' => 'Yemen',
261
+ 'YT' => 'Mayotte',
262
+ 'ZA' => 'South Africa',
263
+ 'ZM' => 'Zambia',
264
+ 'ZW' => 'Zimbabwe'
265
+ }.freeze
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Rack
6
+ class Cloudflare
7
+ class Headers
8
+ # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
9
+ NAMES = %w[
10
+ HTTP_CF_IPCOUNTRY
11
+ HTTP_CF_CONNECTING_IP
12
+ HTTP_CF_RAY
13
+ HTTP_CF_VISITOR
14
+ ].freeze
15
+
16
+ STANDARD = %w[
17
+ HTTP_X_FORWARDED_FOR
18
+ HTTP_X_FORWARDED_PROTO
19
+ REMOTE_ADDR
20
+ ].freeze
21
+
22
+ ALL = (NAMES + STANDARD).freeze
23
+
24
+ # Create constants for each header
25
+ ALL.map { |h| const_set h, h.to_s.freeze }.freeze
26
+
27
+ class << self
28
+ attr_accessor :backup, :original_remote_addr, :original_forwarded_for
29
+
30
+ def trusted?(headers)
31
+ Headers.new(headers).trusted?
32
+ end
33
+ end
34
+
35
+ self.backup = true
36
+ self.original_remote_addr = 'ORIGINAL_REMOTE_ADDR'
37
+ self.original_forwarded_for = 'ORIGINAL_FORWARDED_FOR'
38
+
39
+ def initialize(headers)
40
+ @headers = headers
41
+ end
42
+
43
+ # "Cf-Ipcountry: US"
44
+ def ip_country
45
+ @headers.fetch(HTTP_CF_IPCOUNTRY, 'XX')
46
+ end
47
+
48
+ # "CF-Connecting-IP: A.B.C.D"
49
+ def connecting_ip
50
+ @connecting_ip ||= IPs.parse(@headers[HTTP_CF_CONNECTING_IP]).first
51
+ end
52
+
53
+ # "X-Forwarded-For: A.B.C.D"
54
+ # "X-Forwarded-For: A.B.C.D[,X.X.X.X,Y.Y.Y.Y,]"
55
+ def forwarded_for
56
+ @forwarded_for ||= IPs.parse(@headers[HTTP_X_FORWARDED_FOR])
57
+ end
58
+
59
+ # "X-Forwarded-Proto: https"
60
+ def forwarded_proto
61
+ @headers[HTTP_X_FORWARDED_PROTO]
62
+ end
63
+
64
+ # "Cf-Ray: 230b030023ae2822-SJC"
65
+ def ray
66
+ @headers[HTTP_CF_RAY]
67
+ end
68
+
69
+ # "Cf-Visitor: { \"scheme\":\"https\"}"
70
+ def visitor
71
+ return unless has?(HTTP_CF_VISITOR)
72
+ JSON.parse @headers[HTTP_CF_VISITOR]
73
+ end
74
+
75
+ def remote_addr
76
+ @remote_addr ||= IPs.parse(@headers[REMOTE_ADDR]).first
77
+ end
78
+
79
+ # Indicates if the headers passed through Cloudflare
80
+ def trusted?
81
+ IPs.list.any? { |range| range.include? remote_addr }
82
+ end
83
+
84
+ def backup_headers
85
+ return {} unless Headers.backup
86
+
87
+ {}.tap do |headers|
88
+ headers[Headers.original_remote_addr] = @headers[REMOTE_ADDR]
89
+ headers[Headers.original_forwarded_for] = @headers[HTTP_X_FORWARDED_FOR]
90
+ end
91
+ end
92
+
93
+ def rewritten_headers
94
+ # Only rewrites headers if it's a Cloudflare request
95
+ return {} unless trusted?
96
+
97
+ {}.tap do |headers|
98
+ headers.merge! backup_headers
99
+
100
+ # Overwrite the original remote IP based on
101
+ # Cloudflare's specified original remote IP
102
+ headers[REMOTE_ADDR] = connecting_ip.to_s if connecting_ip
103
+
104
+ # Add HTTP_X_FORWARDED_FOR if it wasn't present.
105
+ # Cloudflare will already have modified the header if
106
+ # it was present in the original request.
107
+ # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
108
+ headers[HTTP_X_FORWARDED_FOR] = "#{connecting_ip}, #{remote_addr}" if forwarded_for.none?
109
+ end
110
+ end
111
+
112
+ # Headers that relate to Cloudflare
113
+ # See: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
114
+ def target_headers
115
+ @headers.select { |k, _| ALL.include? k }
116
+ end
117
+
118
+ def rewritten_target_headers
119
+ target_headers.merge(rewritten_headers)
120
+ end
121
+
122
+ def rewrite
123
+ @headers.merge(rewritten_headers)
124
+ end
125
+
126
+ def has?(header)
127
+ @headers.key?(header)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'net/http'
5
+
6
+ module Rack
7
+ class Cloudflare
8
+ module IPs
9
+ # See: https://www.cloudflare.com/ips/
10
+ V4_URL = 'https://www.cloudflare.com/ips-v4'
11
+ V6_URL = 'https://www.cloudflare.com/ips-v6'
12
+
13
+ class << self
14
+ # List of IPs to reference
15
+ attr_accessor :list
16
+
17
+ # Refresh list of IPs in case local copy is outdated
18
+ def refresh!
19
+ self.list = fetch(V4_URL) + fetch(V6_URL)
20
+ end
21
+
22
+ def fetch(url)
23
+ parse Net::HTTP.get(URI(url))
24
+ end
25
+
26
+ def read(filename)
27
+ parse File.read(filename)
28
+ end
29
+
30
+ def parse(string)
31
+ return [] if string.to_s.strip.empty?
32
+ string.split(/[,\s]+/).map { |ip| IPAddr.new(ip.strip) }
33
+ end
34
+ end
35
+
36
+ V4 = read("#{__dir__}/../../../data/ips_v4.txt")
37
+ V6 = read("#{__dir__}/../../../data/ips_v6.txt")
38
+
39
+ DEFAULTS = V4 + V6
40
+
41
+ ### Configure
42
+
43
+ self.list = DEFAULTS
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Cloudflare
5
+ module Middleware
6
+ class AccessControl
7
+ class << self
8
+ attr_accessor :blocked_response, :blocked_message
9
+ end
10
+
11
+ self.blocked_message = 'Forbidden'
12
+ self.blocked_response = lambda do |_env|
13
+ [403, { 'Content-Type' => 'text/plain' }, ["#{blocked_message.strip}\n"]]
14
+ end
15
+
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(env)
21
+ headers = Headers.new(env)
22
+
23
+ if headers.trusted?
24
+ Cloudflare.info "[#{self.class.name}] Trusted Network (REMOTE_ADDR): #{headers.target_headers}"
25
+ @app.call(env)
26
+ else
27
+ Cloudflare.warn "[#{self.class.name}] Untrusted Network (REMOTE_ADDR): #{headers.target_headers}"
28
+ AccessControl.blocked_response.call(env)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Cloudflare
5
+ module Middleware
6
+ class RewriteHeaders
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ headers = Headers.new(env)
13
+
14
+ Cloudflare.warn "[#{self.class.name}] Untrusted Network (REMOTE_ADDR): #{headers.target_headers}" unless headers.trusted?
15
+ Cloudflare.debug "[#{self.class.name}] Target Headers: #{headers.target_headers}"
16
+ Cloudflare.debug "[#{self.class.name}] Rewritten Headers: #{headers.rewritten_target_headers}"
17
+
18
+ @app.call(headers.rewrite)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
- module Cloudflare
3
- VERSION = '0.1.0'.freeze
4
+ class Cloudflare
5
+ VERSION = '1.0.0'
4
6
  end
5
7
  end
Binary file
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  lib = File.expand_path('lib', __dir__)
2
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
5
  require 'rack/cloudflare/version'
@@ -24,4 +26,6 @@ Gem::Specification.new do |spec|
24
26
  spec.add_development_dependency 'bundler', '~> 1.16'
25
27
  spec.add_development_dependency 'rake', '~> 10.0'
26
28
  spec.add_development_dependency 'rspec', '~> 3.0'
29
+ spec.add_development_dependency 'rubocop'
30
+ spec.add_development_dependency 'rubycritic'
27
31
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-cloudflare
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Van Horn
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-09-13 00:00:00.000000000 Z
11
+ date: 2018-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubycritic
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
55
83
  description: Deal with Cloudflare features in Rack-based apps.
56
84
  email:
57
85
  - joel@joelvanhorn.com
@@ -61,14 +89,23 @@ extra_rdoc_files: []
61
89
  files:
62
90
  - ".gitignore"
63
91
  - ".rspec"
92
+ - ".rubocop.yml"
64
93
  - ".travis.yml"
65
94
  - Gemfile
66
95
  - README.md
67
96
  - Rakefile
68
97
  - bin/console
69
98
  - bin/setup
99
+ - data/ips_v4.txt
100
+ - data/ips_v6.txt
70
101
  - lib/rack/cloudflare.rb
102
+ - lib/rack/cloudflare/countries.rb
103
+ - lib/rack/cloudflare/headers.rb
104
+ - lib/rack/cloudflare/ips.rb
105
+ - lib/rack/cloudflare/middleware/access_control.rb
106
+ - lib/rack/cloudflare/middleware/rewrite_headers.rb
71
107
  - lib/rack/cloudflare/version.rb
108
+ - rack-cloudflare-0.1.0.gem
72
109
  - rack-cloudflare.gemspec
73
110
  homepage: https://github.com/joelvh/rack-cloudflare
74
111
  licenses: []