otto 2.6.0 → 2.8.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/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +254 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +13 -11
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +180 -0
- data/docs/migrating/v2.3.0.md +55 -22
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/env_keys.rb +58 -12
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/privacy/config.rb +245 -3
- data/lib/otto/privacy/core.rb +104 -14
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
- data/lib/otto/privacy.rb +3 -1
- data/lib/otto/request.rb +25 -9
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +61 -1
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +228 -18
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +11 -3
- metadata +5 -2
|
@@ -10,8 +10,20 @@ class Otto
|
|
|
10
10
|
# Automatically masks IP addresses for privacy by default. Original IPs
|
|
11
11
|
# are never stored unless privacy is explicitly disabled.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Otto pins this middleware to the OUTERMOST position of the stack (the
|
|
14
|
+
# :entrypoint tier — see Otto::Core::MiddlewareStack#add_with_position),
|
|
15
|
+
# so it is the first middleware to touch a request and every other
|
|
16
|
+
# middleware, plus the application, reads masked IPs by default. Before
|
|
17
|
+
# #219 it was registered `position: :first`, which is first-in-array and
|
|
18
|
+
# therefore INNERMOST: only the wrapped app saw masked values while every
|
|
19
|
+
# other middleware still saw the raw peer address.
|
|
20
|
+
#
|
|
21
|
+
# Because it now runs ahead of everything, facts about the ORIGINAL peer
|
|
22
|
+
# that downstream code can no longer derive from the (masked) REMOTE_ADDR
|
|
23
|
+
# are recorded first, as leak-free booleans — never as addresses:
|
|
24
|
+
# env['otto.via_trusted_proxy'] (only when proxy trust is configured —
|
|
25
|
+
# absent otherwise, see the tri-state note in #call) and
|
|
26
|
+
# env['otto.peer_loopback'].
|
|
15
27
|
#
|
|
16
28
|
# @example Default behavior (privacy enabled)
|
|
17
29
|
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
@@ -32,9 +44,6 @@ class Otto
|
|
|
32
44
|
@app = app
|
|
33
45
|
@security_config = security_config
|
|
34
46
|
@config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
|
|
35
|
-
|
|
36
|
-
# Privacy is enabled by default unless explicitly disabled
|
|
37
|
-
@privacy_enabled = @config.enabled?
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
# Process request with IP privacy
|
|
@@ -46,19 +55,46 @@ class Otto
|
|
|
46
55
|
# canonical client IP for this request, do not re-resolve or re-mask.
|
|
47
56
|
# This makes stacking two instances (e.g. an app-level mount plus
|
|
48
57
|
# Otto's built-in router mount) order-safe instead of double-masking.
|
|
49
|
-
|
|
58
|
+
if env.key?('otto.client_ip')
|
|
59
|
+
ensure_ip_match_present(env)
|
|
60
|
+
return @app.call(env)
|
|
61
|
+
end
|
|
50
62
|
|
|
51
63
|
# Record the connecting peer's trust decision BEFORE any masking, so
|
|
52
64
|
# secure? can authorize X-Forwarded-Proto canonically even after
|
|
53
65
|
# REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
|
|
54
66
|
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
67
|
+
# TRI-STATE: the key is written ONLY when the operator configured
|
|
68
|
+
# proxy trust (CIDR matchers or a depth). Present, its value is
|
|
69
|
+
# authoritative in both directions — true means the peer matched a
|
|
70
|
+
# CIDR (filter mode) or depth mode is active (configuring a depth
|
|
71
|
+
# asserts the connecting peer IS the operator's proxy tier, #226);
|
|
72
|
+
# false means trust IS configured and this peer failed it. Absent
|
|
73
|
+
# means no proxy trust is configured at all, so downstream consumers
|
|
74
|
+
# may fall back to their own heuristics without this key vetoing
|
|
75
|
+
# them. Writing false on unconfigured deployments made false
|
|
76
|
+
# ambiguous between "untrusted peer" and "nothing configured", which
|
|
77
|
+
# forced consumers into grant-only reads (#228).
|
|
78
|
+
# respond_to?: like geo_headers_trusted?, a partial/duck-typed
|
|
79
|
+
# config (or nil) that cannot report trust state is "unconfigured".
|
|
80
|
+
if @security_config.respond_to?(:proxy_trust_configured?) && @security_config.proxy_trust_configured?
|
|
81
|
+
env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
|
|
82
|
+
end
|
|
60
83
|
|
|
61
|
-
|
|
84
|
+
# Same rationale, for loopback: this middleware runs outermost, so a
|
|
85
|
+
# downstream middleware that must authenticate a DIRECT LOCAL CALL
|
|
86
|
+
# (Otto::CaddyTLS::LocalhostGuard) can no longer read the true socket
|
|
87
|
+
# peer from REMOTE_ADDR. Record the verdict here, on the untouched
|
|
88
|
+
# peer, as a boolean — the address itself is never exposed.
|
|
89
|
+
#
|
|
90
|
+
# Deliberately the raw peer, NOT the resolved client IP: resolution
|
|
91
|
+
# honors forwarded headers from trusted proxies, and a co-located
|
|
92
|
+
# reverse proxy on loopback is itself a natural trusted proxy, so
|
|
93
|
+
# resolving first would let `X-Forwarded-For: 127.0.0.1` promote a
|
|
94
|
+
# remote caller to "localhost".
|
|
95
|
+
env['otto.peer_loopback'] = Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
|
|
96
|
+
|
|
97
|
+
if privacy_enabled?
|
|
62
98
|
apply_privacy(env)
|
|
63
99
|
else
|
|
64
100
|
apply_no_privacy(env)
|
|
@@ -69,6 +105,56 @@ class Otto
|
|
|
69
105
|
|
|
70
106
|
private
|
|
71
107
|
|
|
108
|
+
# Whether IP privacy is on for this request.
|
|
109
|
+
#
|
|
110
|
+
# Read live from the config rather than cached at construction. Otto
|
|
111
|
+
# builds its middleware stack at the end of Otto.new, but
|
|
112
|
+
# configure_ip_privacy stays legal until the first request (the
|
|
113
|
+
# configuration freeze is deferred — see Otto#initialize). A flag
|
|
114
|
+
# captured in #initialize would therefore ignore a post-construction
|
|
115
|
+
# `configure_ip_privacy(profile: :audit)` and keep masking under a
|
|
116
|
+
# profile the operator explicitly turned off. One predicate call per
|
|
117
|
+
# request buys that correctness.
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def privacy_enabled?
|
|
121
|
+
@config.enabled?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Guarantee env['otto.ip_match'] exists on the idempotent-return path.
|
|
125
|
+
#
|
|
126
|
+
# Every path in this middleware that sets otto.client_ip installs the
|
|
127
|
+
# capability first, so a second IPPrivacyMiddleware pass that reaches
|
|
128
|
+
# this guard finds both keys and leaves the precise closure in place.
|
|
129
|
+
# (The no-resolvable-IP path installs the capability but never sets
|
|
130
|
+
# otto.client_ip, so a second pass re-runs apply_privacy and reinstalls
|
|
131
|
+
# an equivalent fail-closed closure — idempotent, since there is nothing
|
|
132
|
+
# to double-mask.) The gap is out-of-contract writes: otto.client_ip is
|
|
133
|
+
# documented as "Set by: IPPrivacyMiddleware" (see Otto::EnvKeys), but
|
|
134
|
+
# an app or test harness that sets it directly trips the idempotency
|
|
135
|
+
# guard and leaves the advertised capability nil — downstream policy
|
|
136
|
+
# code then raises NoMethodError on nil.
|
|
137
|
+
#
|
|
138
|
+
# The repair installs a fail-closed check, NOT one derived from
|
|
139
|
+
# env['otto.client_ip']. That value may already be masked, and matching
|
|
140
|
+
# a masked address against a narrow CIDR produces false ALLOWs (masked
|
|
141
|
+
# 192.168.1.0 falls inside 192.168.1.0/28 when the real client was
|
|
142
|
+
# .200). A universal deny is the safe verdict; the warning below is
|
|
143
|
+
# what makes it diagnosable instead of a silent lockout.
|
|
144
|
+
#
|
|
145
|
+
# @param env [Hash] Rack environment
|
|
146
|
+
def ensure_ip_match_present(env)
|
|
147
|
+
return if env.key?('otto.ip_match')
|
|
148
|
+
|
|
149
|
+
Otto.logger.warn(
|
|
150
|
+
'[IPPrivacyMiddleware] otto.client_ip was set outside this ' \
|
|
151
|
+
'middleware, so otto.ip_match could not be built from the ' \
|
|
152
|
+
'unmasked address; installing a fail-closed check (every CIDR ' \
|
|
153
|
+
'test returns false). Let IPPrivacyMiddleware resolve the client IP.'
|
|
154
|
+
)
|
|
155
|
+
env['otto.ip_match'] = ->(_cidrs) { false }
|
|
156
|
+
end
|
|
157
|
+
|
|
72
158
|
# Apply privacy settings to environment
|
|
73
159
|
#
|
|
74
160
|
# @param env [Hash] Rack environment
|
|
@@ -77,7 +163,19 @@ class Otto
|
|
|
77
163
|
# canonical resolution step; masking below operates on this value.
|
|
78
164
|
client_ip = resolve_client_ip(env)
|
|
79
165
|
|
|
80
|
-
|
|
166
|
+
# Install the verdict-only precision capability while client_ip is
|
|
167
|
+
# still the real address — after this method returns, the raw
|
|
168
|
+
# material is gone (REMOTE_ADDR and forwarded headers rewritten).
|
|
169
|
+
install_ip_match(env, client_ip)
|
|
170
|
+
|
|
171
|
+
# There is deliberately no debug line for the resolution itself. It
|
|
172
|
+
# used to interpolate client_ip, which handed back through the log
|
|
173
|
+
# exactly what these profiles withhold from env — and logs travel
|
|
174
|
+
# further than a process does. Stripped of the address it carried no
|
|
175
|
+
# information worth a line: this method only runs on the masking
|
|
176
|
+
# profiles (:masked / :anonymous), and every branch below already logs
|
|
177
|
+
# its own outcome. To trace resolution here, log a derived value (the
|
|
178
|
+
# masked IP, the family, the trusted-proxy verdict) — never the address.
|
|
81
179
|
|
|
82
180
|
# No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
|
|
83
181
|
# forwarded value). There is nothing to mask, and masking would derive
|
|
@@ -94,9 +192,18 @@ class Otto
|
|
|
94
192
|
# original sensitive data. So still scrub those headers before
|
|
95
193
|
# bailing — a request with no resolvable IP must not leak an
|
|
96
194
|
# un-anonymized User-Agent or Referer.
|
|
195
|
+
#
|
|
196
|
+
# Likewise, forwarded headers may still carry raw client addresses
|
|
197
|
+
# (e.g. an X-Forwarded-For / Forwarded value with no usable REMOTE_ADDR
|
|
198
|
+
# to anchor resolution). There is no masked IP to rewrite them to, so
|
|
199
|
+
# DELETE them — leaving them would leak the raw address downstream.
|
|
97
200
|
if client_ip.to_s.empty?
|
|
98
201
|
Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
|
|
99
|
-
scrub_sensitive_headers(
|
|
202
|
+
scrub_sensitive_headers(
|
|
203
|
+
env,
|
|
204
|
+
Otto::Privacy::RedactedFingerprint.new(env, @config, geo_headers_trusted: geo_headers_trusted?(env))
|
|
205
|
+
)
|
|
206
|
+
scrub_forwarded_headers(env)
|
|
100
207
|
return
|
|
101
208
|
end
|
|
102
209
|
|
|
@@ -119,7 +226,13 @@ class Otto
|
|
|
119
226
|
# localhost / RFC-1918 addresses (the default dev path) even when a
|
|
120
227
|
# correlation_secret is configured. Set mask_private_ips to treat
|
|
121
228
|
# private IPs as public and run them through the full path below.
|
|
122
|
-
|
|
229
|
+
# No address interpolated (see the resolution note at the top of
|
|
230
|
+
# this method). Exempt IPs skip fingerprinting entirely, so there
|
|
231
|
+
# is no derived value to log either — the line records only that
|
|
232
|
+
# the exemption fired. The address is not lost to debugging: this
|
|
233
|
+
# path leaves REMOTE_ADDR unmasked and sets otto.client_ip to the
|
|
234
|
+
# same value, so downstream request logs still carry it.
|
|
235
|
+
Otto.logger.debug '[IPPrivacyMiddleware] Private/localhost IP exempted from masking' if Otto.debug
|
|
123
236
|
return
|
|
124
237
|
end
|
|
125
238
|
end
|
|
@@ -128,7 +241,9 @@ class Otto
|
|
|
128
241
|
# We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
|
|
129
242
|
original_remote_addr = env['REMOTE_ADDR']
|
|
130
243
|
env['REMOTE_ADDR'] = client_ip
|
|
131
|
-
fingerprint = Otto::Privacy::RedactedFingerprint.new(
|
|
244
|
+
fingerprint = Otto::Privacy::RedactedFingerprint.new(
|
|
245
|
+
env, @config, geo_headers_trusted: geo_headers_trusted?(env)
|
|
246
|
+
)
|
|
132
247
|
env['REMOTE_ADDR'] = original_remote_addr
|
|
133
248
|
|
|
134
249
|
# Set privacy-safe values in environment
|
|
@@ -239,6 +354,51 @@ class Otto
|
|
|
239
354
|
Otto::Utils.resolve_client_ip(env, @security_config)
|
|
240
355
|
end
|
|
241
356
|
|
|
357
|
+
# Install env['otto.ip_match']: a verdict-only CIDR membership check
|
|
358
|
+
# over the resolved, UNMASKED client IP.
|
|
359
|
+
#
|
|
360
|
+
# This is the precision axis of the privacy design, decoupled from the
|
|
361
|
+
# observability axis (the privacy profile): policy code downstream —
|
|
362
|
+
# e.g. a per-tenant IP allowlist — can ask "is this client inside
|
|
363
|
+
# these ranges?" at full /32-/128 precision under ANY profile,
|
|
364
|
+
# including full masking. The unmasked address itself never lands in
|
|
365
|
+
# env; only this closure does, and a closure serializes to nothing
|
|
366
|
+
# useful, so env dumps, loggers, and error reporters that walk env
|
|
367
|
+
# cannot leak the IP accidentally.
|
|
368
|
+
#
|
|
369
|
+
# Threat model: the capability is a membership oracle, so deliberate
|
|
370
|
+
# in-process code could reconstruct the address via adaptive queries —
|
|
371
|
+
# but in-process code is already trusted (it could monkeypatch this
|
|
372
|
+
# middleware). The invariant defended is accidental persistence and
|
|
373
|
+
# serialization, and a Proc preserves it where a raw string could not.
|
|
374
|
+
#
|
|
375
|
+
# The closure is installed on every path that resolves an IP (masked,
|
|
376
|
+
# private-exempt, and privacy-disabled). When the request has no
|
|
377
|
+
# resolvable client IP the check returns false — fail-closed for
|
|
378
|
+
# allowlist callers. Invalid CIDR entries raise (configuration error);
|
|
379
|
+
# see Otto::Utils.ip_in_cidrs?.
|
|
380
|
+
#
|
|
381
|
+
# @param env [Hash] Rack environment
|
|
382
|
+
# @param client_ip [String, nil] resolved, unmasked client IP
|
|
383
|
+
def install_ip_match(env, client_ip)
|
|
384
|
+
env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Delete forwarded IP headers outright.
|
|
388
|
+
#
|
|
389
|
+
# Used on the no-resolvable-client-IP path, where there is no masked IP
|
|
390
|
+
# to rewrite these to. Leaving them would leak a raw client address (in
|
|
391
|
+
# X-Forwarded-For / X-Real-IP / X-Client-IP / RFC 7239 Forwarded)
|
|
392
|
+
# downstream. Deleting is Rack-SPEC-safe: an absent CGI key is valid.
|
|
393
|
+
#
|
|
394
|
+
# @param env [Hash] Rack environment
|
|
395
|
+
def scrub_forwarded_headers(env)
|
|
396
|
+
env.delete('HTTP_X_FORWARDED_FOR')
|
|
397
|
+
env.delete('HTTP_X_REAL_IP')
|
|
398
|
+
env.delete('HTTP_X_CLIENT_IP')
|
|
399
|
+
env.delete('HTTP_FORWARDED')
|
|
400
|
+
end
|
|
401
|
+
|
|
242
402
|
# Mask X-Forwarded-For and related proxy headers
|
|
243
403
|
#
|
|
244
404
|
# Replaces forwarded IP headers with the masked IP to prevent leakage
|
|
@@ -261,19 +421,64 @@ class Otto
|
|
|
261
421
|
env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
|
|
262
422
|
env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
|
|
263
423
|
|
|
424
|
+
# RFC 7239 Forwarded carries the client IP in a structured `for=`
|
|
425
|
+
# token, and Otto reads it as an authoritative client-IP source in
|
|
426
|
+
# count-based depth mode (trusted_proxy_header 'Forwarded'/'Both').
|
|
427
|
+
# Left as-is it would leak the real IP to downstream code. Redact only
|
|
428
|
+
# the `for=` value(s) so proto=/host=/by= metadata survives.
|
|
429
|
+
if env['HTTP_FORWARDED']
|
|
430
|
+
env['HTTP_FORWARDED'] = Otto::Privacy::IPPrivacy.mask_forwarded_for(env['HTTP_FORWARDED'], masked_ip)
|
|
431
|
+
end
|
|
432
|
+
|
|
264
433
|
Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
|
|
265
434
|
end
|
|
266
435
|
|
|
267
|
-
# Check if
|
|
436
|
+
# Check if the connecting peer counts as a trusted proxy
|
|
437
|
+
#
|
|
438
|
+
# CIDR filter mode checks the peer's identity against the configured
|
|
439
|
+
# matchers. Count-based depth mode has no enumerable matchers — the
|
|
440
|
+
# depth setting itself is the operator's assertion that the peer is
|
|
441
|
+
# their proxy tier — so an active depth grants peer trust outright
|
|
442
|
+
# (#226). Geo-header trust is unaffected: geo_headers_trusted? gates on
|
|
443
|
+
# trusted_proxies_configured?, which stays matcher-only.
|
|
268
444
|
#
|
|
269
445
|
# @param ip [String] IP address to check
|
|
270
446
|
# @return [Boolean] true if IP is from a trusted proxy
|
|
271
447
|
def trusted_proxy?(ip)
|
|
272
448
|
return false unless @security_config
|
|
449
|
+
return true if @security_config.trusted_proxy_depth_mode?
|
|
273
450
|
|
|
274
451
|
@security_config.trusted_proxy?(ip)
|
|
275
452
|
end
|
|
276
453
|
|
|
454
|
+
# Whether request geo headers may be trusted for this request.
|
|
455
|
+
#
|
|
456
|
+
# Geo headers (CF-IPCountry and friends, plus any app-configured header)
|
|
457
|
+
# are client-spoofable unless the request actually arrived through the
|
|
458
|
+
# CDN/proxy that sets them. So Otto trusts them ONLY when it can verify
|
|
459
|
+
# that origin: a request that arrived via a configured CIDR trusted
|
|
460
|
+
# proxy (identity checked against REMOTE_ADDR).
|
|
461
|
+
#
|
|
462
|
+
# Every other case is untrusted, and geo falls to the local database /
|
|
463
|
+
# custom resolver:
|
|
464
|
+
# - Count-based depth mode: the hop setting the header can't be verified
|
|
465
|
+
# as a geo-CDN (depth proxies are often plain load balancers), and
|
|
466
|
+
# depth configures no CIDR matchers, so trusted_proxies_configured? is
|
|
467
|
+
# false here too.
|
|
468
|
+
# - No trusted-proxy configuration: the header is client-supplied and
|
|
469
|
+
# unverifiable. Deployments behind a real CDN should configure
|
|
470
|
+
# trusted_proxies (or a local database) to get header-based geo.
|
|
471
|
+
#
|
|
472
|
+
# @param env [Hash] Rack environment
|
|
473
|
+
# @return [Boolean]
|
|
474
|
+
def geo_headers_trusted?(env)
|
|
475
|
+
sc = @security_config
|
|
476
|
+
return false unless sc.respond_to?(:trusted_proxies_configured?)
|
|
477
|
+
return false unless sc.trusted_proxies_configured?
|
|
478
|
+
|
|
479
|
+
env['otto.via_trusted_proxy'] == true
|
|
480
|
+
end
|
|
481
|
+
|
|
277
482
|
# Apply no-privacy settings (privacy explicitly disabled)
|
|
278
483
|
#
|
|
279
484
|
# When privacy is disabled, original IP is available for
|
|
@@ -284,7 +489,12 @@ class Otto
|
|
|
284
489
|
# Resolve the canonical client IP once, even with privacy disabled, so
|
|
285
490
|
# downstream code can read env['otto.client_ip'] instead of re-deriving
|
|
286
491
|
# it from REMOTE_ADDR / forwarded headers.
|
|
287
|
-
|
|
492
|
+
client_ip = resolve_client_ip(env)
|
|
493
|
+
env['otto.client_ip'] = client_ip
|
|
494
|
+
|
|
495
|
+
# Same precision capability as the privacy-enabled paths, so policy
|
|
496
|
+
# code has one interface regardless of profile.
|
|
497
|
+
install_ip_match(env, client_ip)
|
|
288
498
|
|
|
289
499
|
# Store original values for explicit access when privacy is disabled
|
|
290
500
|
if env['REMOTE_ADDR']
|
|
@@ -80,9 +80,15 @@ class Otto
|
|
|
80
80
|
# Log blocked requests if ActiveSupport is available
|
|
81
81
|
return unless defined?(ActiveSupport::Notifications)
|
|
82
82
|
|
|
83
|
+
# Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
|
|
84
|
+
# subscriber sees the raw peer regardless of where IPPrivacyMiddleware
|
|
85
|
+
# sits in Otto's own stack. Log a masked address, never req.ip: a
|
|
86
|
+
# deployment on the default :masked profile must not write raw client
|
|
87
|
+
# IPs to its logs every time a limit trips (issue #219).
|
|
83
88
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
84
89
|
req = payload[:request]
|
|
85
|
-
Otto.
|
|
90
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
91
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
86
92
|
end
|
|
87
93
|
end
|
|
88
94
|
end
|
data/lib/otto/utils.rb
CHANGED
|
@@ -320,5 +320,105 @@ class Otto
|
|
|
320
320
|
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
321
321
|
false
|
|
322
322
|
end
|
|
323
|
+
|
|
324
|
+
# Whether an address falls inside any of the given CIDR ranges.
|
|
325
|
+
#
|
|
326
|
+
# The general-purpose CIDR-set matcher (allowlists, denylists, network
|
|
327
|
+
# zones), sharing the semantics of the trusted-proxy matcher: the client
|
|
328
|
+
# address is normalized (port stripped, validated) and folded via
|
|
329
|
+
# IPAddr#native so an IPv4-mapped IPv6 peer (::ffff:203.0.113.7) matches
|
|
330
|
+
# an IPv4 range; ranges of the other address family are skipped rather
|
|
331
|
+
# than raising.
|
|
332
|
+
#
|
|
333
|
+
# Ranges are folded through #native too, so the fold is symmetric: a
|
|
334
|
+
# mapped-IPv6 CIDR (::ffff:10.0.0.0/104) matches a plain IPv4 client just
|
|
335
|
+
# as a mapped client matches a plain IPv4 range. Folding only one side
|
|
336
|
+
# made the family check reject the pair and silently drop the entry —
|
|
337
|
+
# wrong verdict, not a raise. #native returns self for ranges that are
|
|
338
|
+
# not IPv4-mapped/compatible, so ordinary IPv4 and IPv6 CIDRs are
|
|
339
|
+
# untouched, and it returns a new IPAddr rather than mutating, so
|
|
340
|
+
# pre-parsed entries in a caller's configuration array stay intact.
|
|
341
|
+
#
|
|
342
|
+
# The fold needs the prefix to cover the mapped marker — /96 or longer.
|
|
343
|
+
# ::ffff:10.0.0.0/104 folds to 10.0.0.0/8; ::ffff:10.0.0.0/64 does not
|
|
344
|
+
# fold at all, because masking zeroes the ffff marker, and so matches
|
|
345
|
+
# neither an IPv4 client nor a mapped one. Write mapped ranges at /96+,
|
|
346
|
+
# or just write the IPv4 CIDR. ::ffff:0:0/96 is the whole mapped space
|
|
347
|
+
# and therefore matches every IPv4 address. Deprecated IPv4-compatible
|
|
348
|
+
# notation (::a.b.c.d) folds on the same terms, on both sides.
|
|
349
|
+
#
|
|
350
|
+
# Asymmetric strictness, on purpose:
|
|
351
|
+
# - `ip` is runtime data — nil, blank, or malformed input returns false
|
|
352
|
+
# (fail-closed for allowlist callers).
|
|
353
|
+
# - `cidrs` entries are configuration — an invalid CIDR string raises
|
|
354
|
+
# IPAddr::InvalidAddressError, because silently skipping an entry
|
|
355
|
+
# narrows an allowlist or widens a denylist. Validate entries at
|
|
356
|
+
# write/boot time; pre-parsed IPAddr entries skip re-parsing here.
|
|
357
|
+
#
|
|
358
|
+
# @param ip [String, IPAddr, nil] address to test (runtime data)
|
|
359
|
+
# @param cidrs [Enumerable<String, IPAddr>, nil] CIDR ranges or host
|
|
360
|
+
# addresses (configuration)
|
|
361
|
+
# @return [Boolean] true when ip is inside at least one range
|
|
362
|
+
# @raise [IPAddr::InvalidAddressError] if a cidrs entry is not a valid
|
|
363
|
+
# IP or CIDR string
|
|
364
|
+
def ip_in_cidrs?(ip, cidrs)
|
|
365
|
+
return false if cidrs.nil?
|
|
366
|
+
|
|
367
|
+
client =
|
|
368
|
+
if ip.is_a?(IPAddr)
|
|
369
|
+
ip.native
|
|
370
|
+
else
|
|
371
|
+
candidate = normalize_ip(ip&.to_s)
|
|
372
|
+
return false unless candidate
|
|
373
|
+
|
|
374
|
+
IPAddr.new(candidate).native
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
cidrs.any? do |entry|
|
|
378
|
+
range = entry.is_a?(IPAddr) ? entry : IPAddr.new(entry.to_s)
|
|
379
|
+
# IPAddr#native builds its result with #clone, which carries frozen
|
|
380
|
+
# state over and then fails to mutate it. Callers who freeze their
|
|
381
|
+
# range configuration (or pass it through Ractor.make_shareable) would
|
|
382
|
+
# hit FrozenError, so hand #native an unfrozen receiver. Gating the dup
|
|
383
|
+
# on a foldable-range predicate would cost more than it saves:
|
|
384
|
+
# #ipv4_compat? is deprecated and warns under -w, and #native already
|
|
385
|
+
# short-circuits to self for anything that does not fold.
|
|
386
|
+
range = range.dup if range.frozen?
|
|
387
|
+
range = range.native
|
|
388
|
+
range.family == client.family && range.include?(client)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Whether an address is on the loopback interface.
|
|
393
|
+
#
|
|
394
|
+
# This is the RAW SOCKET PEER test used to authenticate a direct local call
|
|
395
|
+
# (Otto::CaddyTLS::LocalhostGuard) — and, because IPPrivacyMiddleware now
|
|
396
|
+
# runs outermost and rewrites REMOTE_ADDR, the same test IPPrivacyMiddleware
|
|
397
|
+
# applies to the original peer and records as the leak-free boolean
|
|
398
|
+
# env['otto.peer_loopback']. Shared here so the pre-masking record and the
|
|
399
|
+
# guard's own fallback cannot drift.
|
|
400
|
+
#
|
|
401
|
+
# Fails closed: a blank or unparseable value is non-loopback rather than
|
|
402
|
+
# raising on the hot path.
|
|
403
|
+
#
|
|
404
|
+
# #native folds IPv4-mapped IPv6 (::ffff:127.0.0.1, which dual-stack servers
|
|
405
|
+
# commonly present) so it is recognized as loopback; plain IPAddr#loopback?
|
|
406
|
+
# returns false for the mapped form.
|
|
407
|
+
#
|
|
408
|
+
# Deliberately does NOT strip a ':port' suffix (unlike #private_ip?): a
|
|
409
|
+
# conforming Rack server reports the peer port in REMOTE_PORT, so an
|
|
410
|
+
# unexpected format means something upstream is non-standard and denying is
|
|
411
|
+
# safer than coercing.
|
|
412
|
+
#
|
|
413
|
+
# @param address [String, nil] raw socket peer address
|
|
414
|
+
# @return [Boolean]
|
|
415
|
+
def loopback_address?(address)
|
|
416
|
+
addr = address.to_s.strip
|
|
417
|
+
return false if addr.empty?
|
|
418
|
+
|
|
419
|
+
IPAddr.new(addr).native.loopback?
|
|
420
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
421
|
+
false
|
|
422
|
+
end
|
|
323
423
|
end
|
|
324
424
|
end
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -226,11 +226,19 @@ class Otto
|
|
|
226
226
|
# before first request (before configuration freezing)
|
|
227
227
|
finalize_request_response_classes
|
|
228
228
|
|
|
229
|
-
#
|
|
230
|
-
#
|
|
229
|
+
# IP Privacy is the ENTRY POINT of the stack: the first middleware to touch
|
|
230
|
+
# a request, so everything else — Otto's own middleware, an :outermost pin,
|
|
231
|
+
# and anything the app adds via Otto#use — observes the masked REMOTE_ADDR
|
|
232
|
+
# and the canonical env['otto.client_ip'] (privacy by default for public
|
|
233
|
+
# IPs; private/localhost IPs are automatically exempted from masking).
|
|
234
|
+
#
|
|
235
|
+
# NOT position: :first, which is first-in-ARRAY and therefore INNERMOST —
|
|
236
|
+
# it put IP masking closest to the app and left every other middleware
|
|
237
|
+
# reading the raw peer address (issue #219). See
|
|
238
|
+
# MiddlewareStack#add_with_position for the full position vocabulary.
|
|
231
239
|
@middleware.add_with_position(
|
|
232
240
|
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
233
|
-
position: :
|
|
241
|
+
position: :entrypoint
|
|
234
242
|
)
|
|
235
243
|
end
|
|
236
244
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07
|
|
11
|
+
date: 2026-08-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -144,6 +144,9 @@ files:
|
|
|
144
144
|
- changelog.d/README.md
|
|
145
145
|
- changelog.d/scriv.ini
|
|
146
146
|
- docs/.gitignore
|
|
147
|
+
- docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
|
|
148
|
+
- docs/1108-STREAMING_SUPPORT_SUMMARY.md
|
|
149
|
+
- docs/geo-country.md
|
|
147
150
|
- docs/ipaddr-encoding-quirk.md
|
|
148
151
|
- docs/migrating/v2.0.0-pre1.md
|
|
149
152
|
- docs/migrating/v2.0.0-pre2.md
|