firebase_hosting_client_ip 0.3.1 → 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: e48d62e28d0187e3caf5bb678495c06a775b4b51c9c1d76a28eaa94a54c93b1c
4
- data.tar.gz: 874542528ec7e86f365808c93fa569d551c00d7207ef40bc0d43dc4f9b418907
3
+ metadata.gz: 503e4ef653bdeaaf7f0bba3d5fd3c3df832fe68a9f04cea4439d80b729fa10a0
4
+ data.tar.gz: b42cb16f12bbcc93125259c4068e195f6fa198df0f8db2437703f42520632aa0
5
5
  SHA512:
6
- metadata.gz: 5c06f5e782a887ac0a00a74b64d4bef9cda8edaf24dac7c72aaf7225375cfa4534a77cf9f9a5d84c19a0bdd059edf1a02da67ac3115c92df9bd0e777a7f7b3f3
7
- data.tar.gz: cea83633e7ec6cd55b50ca8ca7a64c0ae4e1c148344e8dfb107302518ac31c559f655ba58193ba91f5646dbd0c828bcbfb970c64fdf059046669b7c8f2151906
6
+ metadata.gz: 948ee3ad5edf5d3548e9ff56a91da34319c6c1c9dd4e73f6e1bc1781359f9d6d822dbfb296b6753526ce1f72bdc3879593d7102320f4e7a44d4c1cb2b766a935
7
+ data.tar.gz: cffdbf0f0d716272553e89c19a3cb406a029fd8e55565406fa051a7214733539d67408f37a8c32935a35a78c912f9a5f78b09a527f95b33b83ee2535ebf2ae57
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.1"
2
+ ".": "1.0.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0](https://github.com/quintsys/firebase_hosting_client_ip/compare/v0.3.1...v1.0.0) (2026-09-04)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * REMOTE_ADDR is no longer overwritten; read the client IP from request.remote_ip rather than request.ip or REMOTE_ADDR. The X-Forwarded-For fallback has been removed -- when Fastly-Client-IP is absent, request.remote_ip returns Rails' own value or the configured sentinel.
9
+
10
+ ### Features
11
+
12
+ * trust only Fastly-Client-IP and stop rewriting REMOTE_ADDR ([#44](https://github.com/quintsys/firebase_hosting_client_ip/issues/44)) ([ad1c5ce](https://github.com/quintsys/firebase_hosting_client_ip/commit/ad1c5ceea4fbcf3b08fe524a9723fe77738f4861))
13
+
3
14
  ## [0.3.1](https://github.com/quintsys/firebase_hosting_client_ip/compare/v0.3.0...v0.3.1) (2025-12-23)
4
15
 
5
16
 
data/README.md CHANGED
@@ -2,114 +2,238 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/firebase_hosting_client_ip.svg)](https://badge.fury.io/rb/firebase_hosting_client_ip)
4
4
 
5
- A Rails middleware gem that normalizes client IP addresses when your Rails application is deployed behind Firebase Hosting.
5
+ Rails middleware that makes `request.remote_ip` return the real client IP when your application is deployed behind Firebase Hosting.
6
6
 
7
7
  ## Problem
8
8
 
9
- When a Rails application is deployed behind Firebase Hosting, the original client IP address is obscured by proxy layers. Rails' default `ActionDispatch::RemoteIp` middleware may not correctly identify the true client IP due to the specific header precedence used by Firebase Hosting's infrastructure.
9
+ Firebase Hosting fronts your application with Fastly (undocumented, not configurable — every Firebase Hosting site gets it). When the origin is Cloud Run, nothing your application sees at the socket belongs to the client: every hop in front of it is Google infrastructure, and each one looks like a legitimate caller.
10
10
 
11
- This gem provides a middleware that implements a heuristic precedence order specifically designed for Firebase Hosting's proxy chain, ensuring `request.remote_ip` returns the correct client IP address.
11
+ ```mermaid
12
+ flowchart TB
13
+ client["👤 Client<br/>203.0.113.99"]
14
+ fastly["Firebase Hosting<br/>Fastly edge"]
15
+ gfe["Google Front End<br/>34.96.0.1"]
16
+ rails["Cloud Run container<br/>Rails app"]
12
17
 
13
- ## Supported Architecture
18
+ client -- "connects" --> fastly
19
+ fastly -- "sets Fastly-Client-IP: 203.0.113.99" --> gfe
20
+ gfe -- "appends itself to X-Forwarded-For" --> rails
14
21
 
15
- This gem is designed for the following architecture:
22
+ rails --> arrives
16
23
 
17
- ```
18
- Client → Firebase Hosting → Rails Application
19
- ```
24
+ subgraph arrives["What arrives at Rails"]
25
+ direction LR
26
+ a1["REMOTE_ADDR &nbsp; 169.254.1.1 &nbsp; — the peer that connected"]
27
+ a2["X-Forwarded-For &nbsp; 203.0.113.99, 34.96.0.1"]
28
+ a3["Fastly-Client-IP &nbsp; 203.0.113.99 &nbsp; — the actual client"]
29
+ end
20
30
 
21
- Firebase Hosting uses Fastly CDN behind the scenes (this is not a documented feature and is not configurable - all Firebase Hosting users get Fastly CDN automatically). The middleware handles the `HTTP_FASTLY_CLIENT_IP` header that Fastly provides, as well as the `HTTP_X_FORWARDED_FOR` header from the proxy chain.
31
+ arrives --> stock
32
+ arrives --> gem
22
33
 
23
- ## Intended Use Cases
34
+ stock["❌ Stock Rails<br/>request.remote_ip → 34.96.0.1<br/>a Google address, not your user"]
35
+ gem["✅ With this gem<br/>request.remote_ip → 203.0.113.99"]
24
36
 
25
- This middleware is useful for:
37
+ classDef bad fill:#ffebe9,stroke:#cf222e,color:#1f2328
38
+ classDef good fill:#dafbe1,stroke:#1a7f37,color:#1f2328
39
+ class stock bad
40
+ class gem good
41
+ ```
26
42
 
27
- - **Logging**: Accurately log client IP addresses for audit trails and debugging
28
- - **Analytics**: Track user locations and behavior based on correct IP geolocation
29
- - **User Experience**: Personalize content based on user location
30
- - **Security**: Implement IP-based rate limiting or access controls
43
+ Rails resolves `remote_ip` by walking `X-Forwarded-For` right-to-left, discarding entries it recognizes as *trusted* proxies. A Google front-end address is public and appears in no trusted-proxy list, so the walk stops there and reports it as the client. Every request then looks like it came from the same handful of Google addresses which quietly breaks rate limiting, IP allowlists, geolocation, and audit trails.
31
44
 
32
- ## Security Disclaimer
45
+ Fastly sets `Fastly-Client-IP` to the end user's address. In this architecture it is the only trustworthy source, and this gem's entire job is to make Rails use it.
33
46
 
34
- **IMPORTANT**: This middleware trusts HTTP headers (`HTTP_FASTLY_CLIENT_IP` and `HTTP_X_FORWARDED_FOR`) to determine the client IP address. These headers can be spoofed by clients if they have direct access to your application.
47
+ ## What it does
35
48
 
36
- **This middleware is only safe to use when:**
37
- - Your Rails application is deployed behind Firebase Hosting (or a trusted proxy/CDN)
38
- - Direct access to your application is blocked (e.g., via firewall rules)
39
- - You trust the proxy infrastructure to set these headers correctly
49
+ One thing:
40
50
 
41
- **Do not use this middleware if:**
42
- - Your application is directly accessible from the internet
43
- - You cannot guarantee that requests pass through Firebase Hosting
44
- - You need strict security guarantees about IP address authenticity
51
+ > If `Fastly-Client-IP` is present and contains a valid IP address, `request.remote_ip` returns it. Otherwise the gem gets out of the way.
45
52
 
46
- For production deployments, ensure your application only accepts traffic through Firebase Hosting and cannot be accessed directly.
53
+ - **`REMOTE_ADDR` is never modified.** `request.ip` keeps reporting the actual peer address; `request.remote_ip` reports the client. They describe different layers, and conflating them was the design mistake in 0.x.
54
+ - **`X-Forwarded-For` is never consulted.** It cannot distinguish the client from Google's infrastructure, and it is attacker-controlled if your service can be reached directly.
55
+ - **IP values are validated, not rewritten.** The header's own spelling is passed through — an expanded IPv6 address is not silently compressed.
56
+
57
+ The middleware is inserted automatically after `ActionDispatch::RemoteIp`, where it replaces the value Rails computed. No application changes are required, and anything reading `request.remote_ip` — your code, Rack::Attack, audit logging, rate limiters — picks it up.
47
58
 
48
59
  ## Installation
49
60
 
50
- Add this line to your application's Gemfile:
61
+ ```ruby
62
+ gem "firebase_hosting_client_ip"
63
+ ```
64
+
65
+ Requires Ruby >= 3.2 and Rails >= 7.0.
66
+
67
+ ## Usage
51
68
 
52
69
  ```ruby
53
- gem 'firebase_hosting_client_ip'
70
+ class ApplicationController < ActionController::Base
71
+ def index
72
+ Rails.logger.info "Request from: #{request.remote_ip}"
73
+ end
74
+ end
54
75
  ```
55
76
 
56
- And then execute:
77
+ ### Configuration
78
+
79
+ There is one option, and it controls a single decision: when there is no trustworthy Fastly IP, does Rails decide, or does your application get a value it can recognize as "unknown"?
80
+
81
+ ```ruby
82
+ # config/initializers/firebase_hosting_client_ip.rb
83
+ FirebaseHostingClientIp.configure do |config|
84
+ # :passthrough (default) - leave Rails' own value alone. Behind Cloud Run
85
+ # that is typically a Google front-end address, not your client.
86
+ # A String - written to request.remote_ip verbatim, so the application can
87
+ # detect it and act accordingly (e.g. fail closed on IP-gated actions).
88
+ config.missing_header_fallback = "0.0.0.0"
89
+ end
90
+ ```
91
+
92
+ Any String is accepted, including `""`; the gem imposes no meaning on the sentinel — that is your application's decision. Non-String values other than `:passthrough` raise `ArgumentError`, because `request.remote_ip` coerces with `to_s` and a `nil` would silently fall back to Rack's own calculation.
93
+
94
+ If you do anything security-adjacent with `request.remote_ip` — allowlisting, rate limiting, audit trails — prefer a sentinel. Passthrough means a Google address flows into that logic looking exactly like a real client.
95
+
96
+ ### A header is considered valid only if it is a bare host address
97
+
98
+ These are all treated as if the header were missing:
99
+
100
+ | Value | Why |
101
+ |---|---|
102
+ | `""`, `" "` | empty |
103
+ | `"not-an-ip"` | unparseable |
104
+ | `"192.0.2.1/24"`, `"2001:db8::1/64"` | a range is not a client address |
105
+ | `"[2001:db8::42]"` | bracketed form |
106
+ | `"2001:db8::42%eth0"` | zone identifier |
107
+
108
+ `IPAddr.new` accepts several of these, so the gem rejects them explicitly rather than passing them to Rails, where they would raise in any consumer that parses the value.
109
+
110
+ ## Security
111
+
112
+ **This middleware trusts an HTTP header, and that header is not cryptographically protected.**
113
+
114
+ Two separate attacks matter, and blocking one does not block the other:
115
+
116
+ 1. **Direct requests to the origin.** Anyone who can reach your Cloud Run service without going through Firebase Hosting can set `Fastly-Client-IP` to anything. Restrict ingress (internal + load balancer only) so this is not possible. This gem cannot help with it — lock down ingress.
117
+ 2. **Requests through Firebase Hosting carrying a forged header.** By Fastly's own documentation, `Fastly-Client-IP` is *not* protected by default: "if a client sets this header themselves, we will use it." Fastly documents a one-line VCL guard that overwrites it with the observed client address, but whether a given customer applies it is that customer's edge configuration, which you do not control or see.
118
+
119
+ **On Firebase Hosting, the edge overwrites a forged header — observed directly, though still not a documented guarantee.** Tested against a live Firebase-fronted Cloud Run app, by reading the header that arrived at the origin (not just the resolved value):
57
120
 
58
121
  ```bash
59
- bundle install
122
+ curl -H 'Fastly-Client-IP: 192.0.2.123' -H 'X-Forwarded-For: 203.0.113.55' \
123
+ https://your-app.example.com/some-endpoint-that-echoes-headers
60
124
  ```
61
125
 
62
- ## Requirements
126
+ The `Fastly-Client-IP` that reached the origin was the caller's **real** address, not the forged `192.0.2.123`. The forged `X-Forwarded-For` did survive into Fastly's own `Fastly-Temp-Xff` header — so the edge *saw* it — but `Fastly-Client-IP` is derived from the connection Fastly observed (`client.ip`), not from any client-supplied header, so the forgery could not reach it. This is Fastly's documented guard in effect:
63
127
 
64
- - Ruby >= 3.2.0
65
- - Rails >= 7.0 (Rails 7, Rails 8, and future versions are supported)
128
+ ```vcl
129
+ if (fastly.ff.visits_this_service == 0 && req.restarts == 0) {
130
+ set req.http.Fastly-Client-IP = client.ip;
131
+ }
132
+ ```
66
133
 
67
- The gem automatically works with whatever Rails version is specified in your application's Gemfile.
134
+ That property computed from the observed connection rather than a forwarded header — is what makes `Fastly-Client-IP` more resistant to spoofing than `X-Forwarded-For` on this path.
68
135
 
69
- ## Usage
136
+ Two caveats remain. It is one deployment's behavior, undocumented by Firebase and outside your control, so it could change without notice. And it says nothing about attack 1 — a request that reaches the origin directly never passes through the edge that sets this header. Treat the CDN sanitization as defense-in-depth, not as the thing your security rests on: lock down ingress, **verify against your own deployment, and re-verify periodically** by confirming the `Fastly-Client-IP` that arrives is the caller's real address rather than a forged one.
70
137
 
71
- The middleware is automatically loaded when Rails is detected. No additional configuration is required.
138
+ ### Choose based on what you use the IP for
72
139
 
73
- The middleware is inserted into the Rails middleware stack after `ActionDispatch::RemoteIp`, ensuring proper precedence in the request processing chain.
140
+ - **Logging, analytics, geolocation, personalization** — safe to use as-is. Even in the worst case (the edge does not sanitize the header), a wrong IP in a subset of requests beats a Google address in all of them.
141
+ - **IP allowlists, rate limiting, fraud signals, audit records used as evidence** — safe *only* when both hold: origin ingress is locked down (attack 1), and you have verified the edge overwrites a forged `Fastly-Client-IP` (attack 2). If you cannot confirm both, an attacker who can forge the header chooses the allowlist entry they match or the bucket they exhaust.
74
142
 
75
- ### Expected Behavior
143
+ Note that `missing_header_fallback` gives no protection here: a forged header is *present and valid*, so it never reaches the fallback path. The gem reports the best available answer; the trustworthiness of that answer comes from the edge, not from the gem.
144
+
145
+ ## What this gem does not cover
146
+
147
+ **`request.ip`** is Rack-level and derives from `REMOTE_ADDR`, which this gem deliberately leaves intact. Anything that must see the client IP should read `request.remote_ip`. For a Rack-level consumer that only has `env`:
148
+
149
+ ```ruby
150
+ env["action_dispatch.remote_ip"].to_s
151
+ ```
76
152
 
77
- After the middleware processes a request, `request.remote_ip` will return the normalized client IP address according to the following precedence:
153
+ **IPv6.** Fastly reports the client's real address, which is frequently IPv6. The gem passes it through untouched. If your application matches against IPv4-only data — allowlists, geo databases, existing audit records — deciding what to do about that is application policy, not something a middleware should guess at.
78
154
 
79
- 1. `HTTP_FASTLY_CLIENT_IP` header (if present and not empty)
80
- 2. Left-most value from `HTTP_X_FORWARDED_FOR` header (if present and not empty)
81
- 3. `REMOTE_ADDR` (the direct connection address)
155
+ ## Alternative: teach Rails to skip Google's ranges
82
156
 
83
- ### Example
157
+ You may not need this gem. Rails already knows how to walk `X-Forwarded-For` — it just doesn't recognize Google's front end as a proxy. Tell it, and it finds the client on its own:
84
158
 
85
159
  ```ruby
86
- class ApplicationController < ActionController::Base
87
- def index
88
- # This will return the correct client IP even behind Firebase Hosting
89
- client_ip = request.remote_ip
90
- Rails.logger.info "Request from: #{client_ip}"
91
- end
92
- end
160
+ # config/application.rb
161
+ config.action_dispatch.trusted_proxies =
162
+ ActionDispatch::RemoteIp::TRUSTED_PROXIES + google_ranges
93
163
  ```
94
164
 
95
- ### Testing the Middleware
165
+ Google publishes the ranges as machine-readable JSON, refreshed daily:
96
166
 
97
- You can verify the middleware is working by checking the `request.remote_ip` value in your controllers or by inspecting the `REMOTE_ADDR` environment variable in your middleware stack.
167
+ | File | Contents |
168
+ |---|---|
169
+ | `https://www.gstatic.com/ipranges/goog.json` | all Google ranges (~145 prefixes) |
170
+ | `https://www.gstatic.com/ipranges/cloud.json` | Google Cloud only (~1100 prefixes) |
98
171
 
99
- ## Development
172
+ Each entry is an `ipv4Prefix` or `ipv6Prefix` you can map to `IPAddr`. Verified: with `X-Forwarded-For: 203.0.113.99, 34.96.0.1`, stock Rails returns `34.96.0.1`; adding the front-end range to `trusted_proxies` makes it return `203.0.113.99`.
100
173
 
101
- 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.
174
+ Three things to weigh before choosing it:
102
175
 
103
- 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
176
+ - **It assumes the client is in `X-Forwarded-For` at all.** Whether Firebase Hosting preserves the original client there rather than only the hop it received is undocumented. Test your own deployment before depending on it; if the client is absent from the header, no amount of range filtering recovers it.
177
+ - **Trust the narrowest range you can.** Trusting all of `cloud.json` means trusting every Google Cloud customer. Anyone with a GCP VM can then send a request with a forged `X-Forwarded-For`, have their own (trusted) address appended behind it, and be attributed the value they chose. Trusting only the specific front-end ranges avoids that.
178
+ - **The ranges move.** Vendoring the list means it goes stale; fetching it at boot makes startup depend on a network call. Either is a maintenance cost this gem does not have.
104
179
 
105
- ## Contributing
180
+ The trade is roughly: this gem reads one header and is done, but depends on Fastly setting it; the `trusted_proxies` route uses only stock Rails, but depends on the client surviving in `X-Forwarded-For` and on you keeping a range list current.
106
181
 
107
- Bug reports and pull requests are welcome on GitHub at https://github.com/quintsys/firebase_hosting_client_ip. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/quintsys/firebase_hosting_client_ip/blob/master/CODE_OF_CONDUCT.md).
182
+ ### Using both
108
183
 
109
- ## License
184
+ They compose, and each covers the other's failure mode. Configure `trusted_proxies` *and* install the gem, leaving `missing_header_fallback` at `:passthrough`:
185
+
186
+ ```
187
+ Fastly-Client-IP present : 198.51.100.7 ← the gem answers
188
+ Fastly-Client-IP absent : 203.0.113.99 ← Rails finds the client via ranges
189
+ ```
190
+
191
+ The fallback path stops meaning "you get a Google address" and starts meaning "you get the client according to `X-Forwarded-For`". This is the strongest configuration for accuracy, and it costs one `config.action_dispatch.trusted_proxies` line. Note it only works with `:passthrough` — a sentinel would override the improved fallback.
192
+
193
+ ### Which wins, and when that is the wrong choice
194
+
195
+ The gem takes precedence: it runs after `ActionDispatch::RemoteIp` and replaces whatever Rails computed. That is right for accuracy — a header set by the hop that actually saw the client beats inferring the client by eliminating known infrastructure from a list that changes daily.
110
196
 
111
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
197
+ **It is the wrong precedence if you are defending against forgery.** The two approaches do not degrade the same way under attack. Given a request that forges *both* headers:
198
+
199
+ ```
200
+ X-Forwarded-For: 1.2.3.4, 203.0.113.99, 151.101.1.1, 34.96.0.1
201
+ Fastly-Client-IP: 1.2.3.4
202
+
203
+ trusted_proxies only : 203.0.113.99 ← the real client
204
+ with this gem : 1.2.3.4 ← the attacker's choice
205
+ ```
112
206
 
113
- ## Code of Conduct
207
+ `X-Forwarded-For` resists this because every real proxy appends the peer it saw, so the true client always lands to the *right* of anything the client invented, and Rails takes the right-most untrusted entry. A forged prefix is simply skipped. `Fastly-Client-IP` carries no such structure: it is one value, and if the CDN does not overwrite a client-supplied one, it is whatever the client said.
208
+
209
+ So installing this gem can *reduce* spoof resistance compared with a correctly configured `trusted_proxies` setup. If you use the client IP for access control rather than observability, prefer `trusted_proxies` — trusting both the CDN and Google hops, using Fastly's published list at `https://api.fastly.com/public-ip-list` alongside Google's — and consider not installing this gem at all. The two precedence orders are already available to you: install it, and the header wins; don't, and the `X-Forwarded-For` walk wins. That is why there is no option to invert it.
210
+
211
+ All of this assumes the client actually survives in `X-Forwarded-For` behind Firebase Hosting, which is undocumented. The `curl` in the [Security](#security) section answers that and the spoofing question in the same request — run it before choosing.
212
+
213
+ ### Why the gem does not do the range filtering itself
214
+
215
+ Because Rails already does it, and the data is not the gem's to ship. Google republishes those prefixes daily; a gem released monthly cannot track them, so it would either vendor a list that goes stale — silently returning a Google address again, exactly the bug this gem exists to fix — or fetch at boot and make your application's startup depend on a network call. `trusted_proxies` is a first-class Rails setting and the right home for it.
216
+
217
+ ## Upgrading from 0.x
218
+
219
+ Breaking changes in 1.0:
220
+
221
+ - **`REMOTE_ADDR` is no longer overwritten.** If you relied on reading the client IP from `REMOTE_ADDR` or `request.ip`, switch to `request.remote_ip`.
222
+ - **The `X-Forwarded-For` fallback is gone.** When `Fastly-Client-IP` is absent, 0.x returned the left-most `X-Forwarded-For` entry — a value that is either Google's infrastructure or attacker-supplied. 1.0 returns Rails' own value, or your configured sentinel.
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ bin/setup
228
+ bundle exec rspec
229
+ bundle exec rubocop
230
+ bundle exec rake # spec + rubocop
231
+ ```
232
+
233
+ ## Contributing
234
+
235
+ Bug reports and pull requests are welcome at https://github.com/quintsys/firebase_hosting_client_ip. Contributors are expected to adhere to the [code of conduct](https://github.com/quintsys/firebase_hosting_client_ip/blob/master/CODE_OF_CONDUCT.md).
236
+
237
+ ## License
114
238
 
115
- Everyone interacting in the FirebaseHostingClientIp project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/quintsys/firebase_hosting_client_ip/blob/master/CODE_OF_CONDUCT.md).
239
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FirebaseHostingClientIp
4
+ # Holds the gem's single configuration option.
5
+ #
6
+ # missing_header_fallback controls what request.remote_ip returns when the
7
+ # Fastly-Client-IP header is absent or invalid:
8
+ #
9
+ # :passthrough (default) - leave Rails' own value untouched. Behind
10
+ # Firebase Hosting -> Cloud Run this is typically a Google front-end
11
+ # address, not the client.
12
+ # any String - written to request.remote_ip as-is, so the application can
13
+ # recognize "client IP unknown" and act on it (e.g. fail closed). The
14
+ # gem imposes no semantics on the sentinel; any String is accepted,
15
+ # including "". It must be a String because Request#remote_ip is
16
+ # `(get_header("action_dispatch.remote_ip") || ip).to_s` - a nil or
17
+ # false env value silently falls through to Rack's calculation.
18
+ class Configuration
19
+ PASSTHROUGH = :passthrough
20
+
21
+ attr_reader :missing_header_fallback
22
+
23
+ def initialize
24
+ @missing_header_fallback = PASSTHROUGH
25
+ end
26
+
27
+ def missing_header_fallback=(value)
28
+ unless value == PASSTHROUGH || value.is_a?(String)
29
+ raise ArgumentError,
30
+ "missing_header_fallback must be :passthrough or a String, " \
31
+ "got #{value.inspect}"
32
+ end
33
+
34
+ @missing_header_fallback = value
35
+ end
36
+ end
37
+
38
+ class << self
39
+ def config
40
+ @config ||= Configuration.new
41
+ end
42
+
43
+ def configure
44
+ yield config
45
+ end
46
+
47
+ def reset_configuration!
48
+ @config = Configuration.new
49
+ end
50
+ end
51
+ end
@@ -1,48 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+
3
5
  module FirebaseHostingClientIp
6
+ # Makes request.remote_ip return the client IP reported by Firebase Hosting.
7
+ #
8
+ # Firebase Hosting fronts the application with Fastly, which sets
9
+ # Fastly-Client-IP to the address of the end user. That header is the only
10
+ # trustworthy source in this architecture: behind Cloud Run, the values Rails
11
+ # derives on its own (from X-Forwarded-For) resolve to a Google front-end
12
+ # address, and are attacker-controlled if the service can be reached directly.
13
+ #
14
+ # The middleware runs after ActionDispatch::RemoteIp and replaces the lazy
15
+ # value that middleware installed at env["action_dispatch.remote_ip"], which
16
+ # is exactly what ActionDispatch::Request#remote_ip reads. REMOTE_ADDR is
17
+ # never modified, so request.ip continues to report the real peer address.
18
+ #
19
+ # When the header is absent or invalid, behavior is governed by
20
+ # FirebaseHostingClientIp.config.missing_header_fallback.
4
21
  class Middleware
22
+ REMOTE_IP_KEY = "action_dispatch.remote_ip"
23
+ HEADER = "HTTP_FASTLY_CLIENT_IP"
24
+
25
+ # Characters that make IPAddr accept something which is not a bare client
26
+ # address. IPAddr.new is more permissive than this contract needs: it
27
+ # parses CIDR ranges ("192.0.2.1/24"), bracketed IPv6 ("[2001:db8::42]"),
28
+ # and zone identifiers ("2001:db8::42%eth0"). None of those are values
29
+ # Rails should hand out as request.remote_ip, so they are rejected before
30
+ # parsing rather than repaired.
31
+ DISALLOWED = %r{[/\[\]%]}
32
+
5
33
  def initialize(app)
6
34
  @app = app
7
35
  end
8
36
 
9
37
  def call(env)
10
- # Extract the normalized client IP using the precedence rules
11
- normalized_ip = extract_client_ip(env)
38
+ ip = validated_fastly_ip(env)
39
+ fallback = FirebaseHostingClientIp.config.missing_header_fallback
12
40
 
13
- # Store the normalized IP in the REMOTE_ADDR so that Rails' request.remote_ip
14
- # will return it without needing ActionDispatch::RemoteIp to reprocess
15
- env["REMOTE_ADDR"] = normalized_ip if normalized_ip
41
+ if ip
42
+ env[REMOTE_IP_KEY] = ip
43
+ elsif fallback != Configuration::PASSTHROUGH
44
+ env[REMOTE_IP_KEY] = fallback
45
+ end
16
46
 
17
47
  @app.call(env)
18
48
  end
19
49
 
20
50
  private
21
51
 
22
- def extract_client_ip(env)
23
- # Precedence:
24
- # 1. HTTP_FASTLY_CLIENT_IP (if present and not empty)
25
- # 2. Left-most value from HTTP_X_FORWARDED_FOR (if present and not empty)
26
- # 3. Fallback to REMOTE_ADDR (already processed by ActionDispatch::RemoteIp)
27
-
28
- extract_fastly_ip(env) || extract_forwarded_for_ip(env) || env["REMOTE_ADDR"]
29
- end
30
-
31
- def extract_fastly_ip(env)
32
- fastly_ip = env["HTTP_FASTLY_CLIENT_IP"]
33
- fastly_ip = fastly_ip.strip if fastly_ip
34
- fastly_ip if fastly_ip&.length&.positive?
35
- end
36
-
37
- def extract_forwarded_for_ip(env)
38
- x_forwarded_for = env["HTTP_X_FORWARDED_FOR"]
39
- return nil unless x_forwarded_for&.length&.positive?
52
+ # Returns the trimmed header value verbatim when it is a single valid host
53
+ # address, nil otherwise.
54
+ #
55
+ # This validates without transforming: IPAddr is used only as a gate, and
56
+ # the string handed to Rails keeps the header's own spelling rather than a
57
+ # canonicalized form. Resolving the trusted header is this gem's only
58
+ # responsibility; imposing an IP canonicalization policy is not.
59
+ def validated_fastly_ip(env)
60
+ raw = env[HEADER]&.strip
61
+ return nil if raw.nil? || raw.empty? || raw.match?(DISALLOWED)
40
62
 
41
- # X-Forwarded-For can contain multiple IPs separated by commas
42
- # The left-most (first) IP is the original client IP
43
- ips = x_forwarded_for.split(",").map(&:strip)
44
- first_ip = ips.first
45
- first_ip if first_ip&.length&.positive?
63
+ IPAddr.new(raw)
64
+ raw
65
+ rescue IPAddr::Error
66
+ nil
46
67
  end
47
68
  end
48
69
  end
@@ -4,14 +4,15 @@ require "rails"
4
4
  require_relative "middleware"
5
5
 
6
6
  module FirebaseHostingClientIp
7
- # Railtie for automatic middleware insertion in Rails applications.
7
+ # Inserts the middleware into the Rails middleware stack.
8
8
  # Supports Rails 7, Rails 8, and future versions.
9
9
  class Railtie < ::Rails::Railtie
10
10
  initializer "firebase_hosting_client_ip.insert_middleware",
11
11
  after: "action_dispatch.remote_ip" do |app|
12
- # Insert the middleware after ActionDispatch::RemoteIp
13
- # This ensures ActionDispatch::RemoteIp has already processed the request
14
- # and we can normalize the IP accordingly
12
+ # Position matters: the middleware must run *after*
13
+ # ActionDispatch::RemoteIp so it can replace the value that middleware
14
+ # installs at env["action_dispatch.remote_ip"]. Inserting it earlier
15
+ # would let RemoteIp overwrite the trusted Fastly value.
15
16
  app.middleware.insert_after(
16
17
  ActionDispatch::RemoteIp,
17
18
  FirebaseHostingClientIp::Middleware
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FirebaseHostingClientIp
4
- VERSION = "0.3.1"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "firebase_hosting_client_ip/version"
4
+ require_relative "firebase_hosting_client_ip/configuration"
4
5
  require_relative "firebase_hosting_client_ip/middleware"
5
6
 
6
7
  module FirebaseHostingClientIp
7
- class Error < StandardError; end
8
8
  end
9
9
 
10
10
  require_relative "firebase_hosting_client_ip/railtie" if defined?(Rails)
@@ -1,4 +1,26 @@
1
1
  module FirebaseHostingClientIp
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ def self.config: () -> Configuration
5
+ def self.configure: () { (Configuration) -> void } -> void
6
+ def self.reset_configuration!: () -> Configuration
7
+
8
+ class Configuration
9
+ PASSTHROUGH: Symbol
10
+
11
+ attr_reader missing_header_fallback: (:passthrough | String)
12
+ def missing_header_fallback=: ((:passthrough | String) value) -> (:passthrough | String)
13
+ end
14
+
15
+ class Middleware
16
+ REMOTE_IP_KEY: String
17
+ HEADER: String
18
+ DISALLOWED: Regexp
19
+
20
+ def initialize: (untyped app) -> void
21
+ def call: (Hash[String, untyped] env) -> untyped
22
+
23
+ private
24
+ def validated_fastly_ip: (Hash[String, untyped] env) -> String?
25
+ end
4
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: firebase_hosting_client_ip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erich N Quintero
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-23 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -24,9 +24,9 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
- description: Provides a Rails middleware that resolves the correct client IP when
28
- an application is deployed behind Firebase Hosting, using a heuristic precedence
29
- of headers.
27
+ description: Makes request.remote_ip return the client IP reported by Firebase Hosting's
28
+ Fastly-Client-IP header, the only trustworthy source behind Firebase Hosting. Leaves
29
+ REMOTE_ADDR intact and never infers the client from X-Forwarded-For.
30
30
  email:
31
31
  - qbantek@gmail.com
32
32
  executables: []
@@ -42,6 +42,7 @@ files:
42
42
  - README.md
43
43
  - Rakefile
44
44
  - lib/firebase_hosting_client_ip.rb
45
+ - lib/firebase_hosting_client_ip/configuration.rb
45
46
  - lib/firebase_hosting_client_ip/middleware.rb
46
47
  - lib/firebase_hosting_client_ip/railtie.rb
47
48
  - lib/firebase_hosting_client_ip/version.rb
@@ -73,5 +74,5 @@ requirements: []
73
74
  rubygems_version: 3.4.19
74
75
  signing_key:
75
76
  specification_version: 4
76
- summary: Rails middleware to normalize client IP behind Firebase Hosting
77
+ summary: Rails middleware that makes request.remote_ip trust Fastly-Client-IP
77
78
  test_files: []