rails-cloudflare-origin 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/lib/cloudflare_origin/middleware.rb +115 -0
- data/lib/cloudflare_origin/railtie.rb +25 -0
- data/lib/cloudflare_origin/version.rb +3 -0
- data/lib/cloudflare_origin.rb +3 -0
- data/lib/rails-cloudflare-origin.rb +1 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7407d25d62db057d4b7756bd537f04d307c1dd0da6f4abfbe7688abd8012bb58
|
|
4
|
+
data.tar.gz: 47b0af6ccf5fe1308c4f996e89b1204e3c355e0355b84eb497dac074b5537e1a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 88989bfd4e3c1738280d538eff110b5bfc2def01d13c0c3f89b54b473eb2a1f2ea63964aabddbc53694ad4416bab9c74b91b64296d2cc22f2ad608dbcee87fe1
|
|
7
|
+
data.tar.gz: c2122464fbad49b5158bdd0687bf4109eb7aacd13ea8abfc124cb4910bd4d68fd8d6503682229b96879ea73fedea54d8a3ea120ef4a11d22bc22327be0b8ef2f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kokorono
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# rails-cloudflare-origin
|
|
2
|
+
|
|
3
|
+
For a Rails app served through Cloudflare: refuses requests that did not come through Cloudflare, and sets the client IP from `CF-Connecting-IP` on those that did.
|
|
4
|
+
|
|
5
|
+
Behind Cloudflare and a proxy server in front of the app, `request.remote_ip` tends to return the proxy's own address for every request, because the proxy appends itself to `X-Forwarded-For` and Rails does not trust it. Trusting the proxy and Cloudflare's IP ranges instead means a list to keep current. This gem takes a different route: once a request has proved it came through Cloudflare, the address Cloudflare put in `CF-Connecting-IP` is the visitor.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "rails-cloudflare-origin"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A Railtie installs the middleware at the top of the stack, above anything the app inserts with `insert_before 0` from an initializer or an environment file, and above Rails' own `HostAuthorization` and `ActionDispatch::SSL`. Nothing else is needed in the app.
|
|
14
|
+
|
|
15
|
+
## What it does on each request
|
|
16
|
+
|
|
17
|
+
1. Removes `Forwarded`, `Client-IP`, `X-Forwarded-For`, `X-Real-IP` and `X-Forwarded-Host`. A visitor can send any of these, and conflicting values make Rails raise `ActionDispatch::RemoteIp::IpSpoofAttackError`, which surfaces as a 500. `X-Forwarded-Proto` and `X-Forwarded-Port` stay.
|
|
18
|
+
2. Takes the `X-Origin-Secret` header out of the request, so it never reaches the app, its logs, or an error tracker.
|
|
19
|
+
3. Lets a `GET` or `HEAD` of exactly `/up` through without the secret when it carries no `CF-Ray` header. This is the platform's health check, which reaches the app directly instead of through Cloudflare. A `/up` that came through Cloudflare still needs the secret.
|
|
20
|
+
4. Answers anything else without a matching secret with a `403` and `cache-control: no-store`.
|
|
21
|
+
5. Sets `REMOTE_ADDR` to `CF-Connecting-IP`, so `request.ip` and `request.remote_ip` both return the visitor. A request that carries the secret but no `CF-Connecting-IP`, or one that is not an IP address, raises `CloudflareOrigin::InvalidClientIpError`. Cloudflare always sends that header, so its absence means a zone setting changed, and an error is easier to notice than every visitor quietly sharing one address. The middleware runs above Rails' own error handling, so it reports the error to `Rails.error` itself before raising: any error tracker subscribed to `Rails.error` receives it, and the request ends as a 500 from the app server.
|
|
22
|
+
|
|
23
|
+
## What it trusts
|
|
24
|
+
|
|
25
|
+
- **The origin secret.** A request carrying it came through your Cloudflare zone, because only the zone adds it.
|
|
26
|
+
- **`CF-Connecting-IP` on such a request.** Cloudflare refuses a request in which the visitor sent that header themselves (Error 1000), so on a request that passed through the zone, the value is Cloudflare's.
|
|
27
|
+
|
|
28
|
+
## Cloudflare setup it assumes
|
|
29
|
+
|
|
30
|
+
- A Request Header Transform Rule on the zone sets `X-Origin-Secret` to the secret the app holds, on every request to the app's hostnames. Generate the value with `openssl rand -hex 32`.
|
|
31
|
+
- The "Remove visitor IP headers" managed transform is off, and no other Transform Rule or Worker removes or rewrites `CF-Connecting-IP`.
|
|
32
|
+
- Leave Pseudo IPv4 off: in Cloudflare's "overwrite headers" mode an IPv6 visitor's address is replaced with a made-up IPv4 address from `240.0.0.0/4`, and the gem records that address.
|
|
33
|
+
|
|
34
|
+
## Secrets
|
|
35
|
+
|
|
36
|
+
The gem reads two secrets, each from an environment variable first and from the app's credentials second:
|
|
37
|
+
|
|
38
|
+
| Secret | Environment variable | Credential |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| Current, required to switch the gem on | `CLOUDFLARE_ORIGIN_SECRET` | `cloudflare_origin_secret` |
|
|
41
|
+
| Previous, being rotated out, optional | `CLOUDFLARE_ORIGIN_SECRET_PREVIOUS` | `cloudflare_origin_secret_previous` |
|
|
42
|
+
|
|
43
|
+
Each secret is resolved on its own: a non-blank environment variable wins over the credential. A variable left set on the host therefore overrides the credential without any warning, so a stale one keeps an old value in force after you rotate the credential.
|
|
44
|
+
|
|
45
|
+
Both secrets are accepted, so the value rotates without an outage: set the new value as the current secret and the old one as the previous secret, deploy, switch the Transform Rule to the new value, then remove the previous one.
|
|
46
|
+
|
|
47
|
+
A secret that is present but shorter than 32 characters fails the boot, so a broken value stops a deploy instead of answering every request with a 403. A blank secret counts as absent.
|
|
48
|
+
|
|
49
|
+
## When it is on
|
|
50
|
+
|
|
51
|
+
The middleware is installed only when a current secret is present, and never in the `development` and `test` environments, even when a secret is set, so local requests and specs see the headers they send.
|
|
52
|
+
|
|
53
|
+
When no current secret is present, the gem installs nothing. Outside `development` and `test` it logs a warning at boot saying the middleware is not installed. An asset build that boots without credentials has no secret, so it is simply off.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
Ruby 4.0 or newer and Rails 8.1 or newer. The gem targets the latest Ruby and Rails only, and these floors move up with each release.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bundle install
|
|
63
|
+
bundle exec rake test
|
|
64
|
+
bundle exec standardrb
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_support/core_ext/enumerable"
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "active_support/security_utils"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "resolv"
|
|
7
|
+
|
|
8
|
+
module CloudflareOrigin
|
|
9
|
+
# Raised for a request that carries the origin secret but no usable CF-Connecting-IP. Cloudflare
|
|
10
|
+
# always sends that header, so its absence means a zone setting or Worker changed it; failing loudly
|
|
11
|
+
# beats recording the proxy's address as every visitor's without anyone noticing.
|
|
12
|
+
class InvalidClientIpError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Answers only requests that came through Cloudflare, and takes the client IP from
|
|
15
|
+
# CF-Connecting-IP on those. A Request Header Transform Rule on the zone sets X-Origin-Secret to a
|
|
16
|
+
# secret the app also holds, so a request that reached the origin directly lacks it and gets a 403.
|
|
17
|
+
#
|
|
18
|
+
# Two secrets are accepted so the value can rotate without an outage: set the new one as the
|
|
19
|
+
# current secret and the old one as the previous secret, deploy, switch the Cloudflare rule to the
|
|
20
|
+
# new value, then drop the previous one.
|
|
21
|
+
#
|
|
22
|
+
# A GET or HEAD of /up stays open to the platform's health check, which reaches the app directly
|
|
23
|
+
# and cannot carry the header. A /up that came through Cloudflare (it carries CF-Ray) still needs
|
|
24
|
+
# the secret, so a smoke check against the public hostname fails when the rule is missing or wrong.
|
|
25
|
+
class Middleware
|
|
26
|
+
SECRET_HEADER = "HTTP_X_ORIGIN_SECRET".freeze
|
|
27
|
+
|
|
28
|
+
# With these gone and REMOTE_ADDR set to the visitor, Rack's request.ip and Rails' remote_ip
|
|
29
|
+
# both return REMOTE_ADDR, and Rails' spoofing check has nothing to compare, so a forged header
|
|
30
|
+
# can neither choose the client IP nor raise IpSpoofAttackError. X-Real-IP goes too, because some error trackers take the client IP from it.
|
|
31
|
+
# X-Forwarded-Proto and X-Forwarded-Port stay, since Cloudflare and the proxy server in front of
|
|
32
|
+
# the app set them.
|
|
33
|
+
FORWARDING_HEADERS = %w[
|
|
34
|
+
HTTP_CLIENT_IP
|
|
35
|
+
HTTP_FORWARDED
|
|
36
|
+
HTTP_X_FORWARDED_FOR
|
|
37
|
+
HTTP_X_FORWARDED_HOST
|
|
38
|
+
HTTP_X_REAL_IP
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
CLIENT_IP_HEADER = "HTTP_CF_CONNECTING_IP".freeze
|
|
42
|
+
|
|
43
|
+
HEALTH_CHECK_METHODS = %w[GET HEAD].freeze
|
|
44
|
+
|
|
45
|
+
# `openssl rand -hex 32` makes 64 characters, so anything shorter than half of that is a
|
|
46
|
+
# placeholder or a paste gone wrong, and guessable.
|
|
47
|
+
MINIMUM_LENGTH = 32
|
|
48
|
+
|
|
49
|
+
def initialize(app, current:, previous:)
|
|
50
|
+
@app = app
|
|
51
|
+
current, previous = current.to_s, previous.to_s
|
|
52
|
+
# A blank value would 403 every page. Failing at boot stops the release, so no machine is
|
|
53
|
+
# replaced.
|
|
54
|
+
raise ArgumentError, "the current Cloudflare origin secret is blank" if current.blank?
|
|
55
|
+
|
|
56
|
+
secrets = [current, previous].compact_blank
|
|
57
|
+
if secrets.any? { it.length < MINIMUM_LENGTH }
|
|
58
|
+
raise ArgumentError, "a Cloudflare origin secret is shorter than #{MINIMUM_LENGTH} characters"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@digests = secrets.map { digest it }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(env)
|
|
65
|
+
# Removed before the /up decision, so the unguarded health check path is covered too.
|
|
66
|
+
FORWARDING_HEADERS.each { env.delete it }
|
|
67
|
+
# Removed before anything downstream runs, so the value never reaches logs or error reports.
|
|
68
|
+
header = env.delete SECRET_HEADER
|
|
69
|
+
return @app.call env if rails_health_check? env
|
|
70
|
+
return forbidden unless header
|
|
71
|
+
|
|
72
|
+
given = digest header
|
|
73
|
+
return forbidden unless @digests.any? { ActiveSupport::SecurityUtils.fixed_length_secure_compare it, given }
|
|
74
|
+
|
|
75
|
+
env["REMOTE_ADDR"] = client_ip env
|
|
76
|
+
@app.call env
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def rails_health_check?(env)
|
|
82
|
+
env["PATH_INFO"] == "/up" && HEALTH_CHECK_METHODS.include?(env["REQUEST_METHOD"]) && !env.key?("HTTP_CF_RAY")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def client_ip(env)
|
|
86
|
+
value = env[CLIENT_IP_HEADER]
|
|
87
|
+
raise InvalidClientIpError, "CF-Connecting-IP is missing" if value.blank?
|
|
88
|
+
raise InvalidClientIpError, "CF-Connecting-IP is not an IP address" unless ip_address? value
|
|
89
|
+
|
|
90
|
+
value
|
|
91
|
+
rescue InvalidClientIpError => error
|
|
92
|
+
# This middleware sits above ActionDispatch::Executor, so Rails' own error reporting never sees
|
|
93
|
+
# the raise; only an app-server hook would. Reporting here reaches every Rails.error subscriber.
|
|
94
|
+
ActiveSupport.error_reporter.report error, handled: false, source: "rails-cloudflare-origin"
|
|
95
|
+
raise
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The Resolv patterns match a bare address only, where IPAddr.new would also take a CIDR range.
|
|
99
|
+
def ip_address?(value)
|
|
100
|
+
value.match?(Resolv::IPv4::Regex) || value.match?(Resolv::IPv6::Regex)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A fresh response each time, so a caller that adds headers to one cannot change the next. Never
|
|
104
|
+
# cached, so a 403 served while the rule's value is wrong does not outlive the fix at
|
|
105
|
+
# Cloudflare's edge.
|
|
106
|
+
def forbidden
|
|
107
|
+
[403, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Forbidden"]]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Compared as fixed-length digests, so values of different lengths neither raise nor return early.
|
|
111
|
+
def digest(value)
|
|
112
|
+
Digest::SHA256.digest value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module CloudflareOrigin
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "cloudflare_origin.middleware", after: :load_config_initializers do |app|
|
|
6
|
+
next if Rails.env.local?
|
|
7
|
+
|
|
8
|
+
current = Railtie.secret app, :cloudflare_origin_secret
|
|
9
|
+
if current.blank?
|
|
10
|
+
Rails.logger.warn "rails-cloudflare-origin: middleware not installed because no origin secret is configured"
|
|
11
|
+
next
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
app.config.middleware.insert_before 0, Middleware,
|
|
15
|
+
current:,
|
|
16
|
+
previous: Railtie.secret(app, :cloudflare_origin_secret_previous)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The environment variable wins when set, so a host can supply the secret without editing
|
|
20
|
+
# credentials. A stale variable left on the host therefore overrides a rotated credential.
|
|
21
|
+
def self.secret(app, name)
|
|
22
|
+
ENV[name.to_s.upcase].presence || app.credentials[name]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "cloudflare_origin"
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-cloudflare-origin
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kokorono
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.1'
|
|
40
|
+
description: A Rack middleware, installed by a Railtie at the top of the stack, that
|
|
41
|
+
requires a Cloudflare-added origin secret, strips client IP headers a visitor can
|
|
42
|
+
forge, and sets the client IP from CF-Connecting-IP.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/cloudflare_origin.rb
|
|
50
|
+
- lib/cloudflare_origin/middleware.rb
|
|
51
|
+
- lib/cloudflare_origin/railtie.rb
|
|
52
|
+
- lib/cloudflare_origin/version.rb
|
|
53
|
+
- lib/rails-cloudflare-origin.rb
|
|
54
|
+
homepage: https://github.com/kokorono-group/rails-cloudflare-origin
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
bug_tracker_uri: https://github.com/kokorono-group/rails-cloudflare-origin/issues
|
|
59
|
+
changelog_uri: https://github.com/kokorono-group/rails-cloudflare-origin/releases
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
61
|
+
source_code_uri: https://github.com/kokorono-group/rails-cloudflare-origin
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '4.0'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 4.0.20
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Refuse requests that bypass Cloudflare and take the client IP from CF-Connecting-IP
|
|
79
|
+
test_files: []
|