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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +11 -0
- data/README.md +185 -61
- data/lib/firebase_hosting_client_ip/configuration.rb +51 -0
- data/lib/firebase_hosting_client_ip/middleware.rb +49 -28
- data/lib/firebase_hosting_client_ip/railtie.rb +5 -4
- data/lib/firebase_hosting_client_ip/version.rb +1 -1
- data/lib/firebase_hosting_client_ip.rb +1 -1
- data/sig/firebase_hosting_client_ip.rbs +23 -1
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 503e4ef653bdeaaf7f0bba3d5fd3c3df832fe68a9f04cea4439d80b729fa10a0
|
|
4
|
+
data.tar.gz: b42cb16f12bbcc93125259c4068e195f6fa198df0f8db2437703f42520632aa0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 948ee3ad5edf5d3548e9ff56a91da34319c6c1c9dd4e73f6e1bc1781359f9d6d822dbfb296b6753526ce1f72bdc3879593d7102320f4e7a44d4c1cb2b766a935
|
|
7
|
+
data.tar.gz: cffdbf0f0d716272553e89c19a3cb406a029fd8e55565406fa051a7214733539d67408f37a8c32935a35a78c912f9a5f78b09a527f95b33b83ee2535ebf2ae57
|
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
|
[](https://badge.fury.io/rb/firebase_hosting_client_ip)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
rails --> arrives
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
subgraph arrives["What arrives at Rails"]
|
|
25
|
+
direction LR
|
|
26
|
+
a1["REMOTE_ADDR 169.254.1.1 — the peer that connected"]
|
|
27
|
+
a2["X-Forwarded-For 203.0.113.99, 34.96.0.1"]
|
|
28
|
+
a3["Fastly-Client-IP 203.0.113.99 — the actual client"]
|
|
29
|
+
end
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
arrives --> stock
|
|
32
|
+
arrives --> gem
|
|
22
33
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
## What it does
|
|
35
48
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
### Choose based on what you use the IP for
|
|
72
139
|
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
165
|
+
Google publishes the ranges as machine-readable JSON, refreshed daily:
|
|
96
166
|
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
Three things to weigh before choosing it:
|
|
102
175
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
+
### Using both
|
|
108
183
|
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
38
|
+
ip = validated_fastly_ip(env)
|
|
39
|
+
fallback = FirebaseHostingClientIp.config.missing_header_fallback
|
|
12
40
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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,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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
77
|
+
summary: Rails middleware that makes request.remote_ip trust Fastly-Client-IP
|
|
77
78
|
test_files: []
|