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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +254 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +13 -11
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +180 -0
  18. data/docs/migrating/v2.3.0.md +55 -22
  19. data/docs/reverse-proxy-network-services.md +19 -6
  20. data/examples/simple_geo_resolver.rb +38 -5
  21. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  22. data/lib/otto/core/middleware_stack.rb +72 -25
  23. data/lib/otto/env_keys.rb +58 -12
  24. data/lib/otto/logging_helpers.rb +50 -1
  25. data/lib/otto/mcp/rate_limiting.rb +5 -2
  26. data/lib/otto/privacy/config.rb +245 -3
  27. data/lib/otto/privacy/core.rb +104 -14
  28. data/lib/otto/privacy/geo_resolver.rb +228 -128
  29. data/lib/otto/privacy/ip_privacy.rb +24 -0
  30. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  31. data/lib/otto/privacy.rb +3 -1
  32. data/lib/otto/request.rb +25 -9
  33. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  34. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  35. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  36. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  37. data/lib/otto/security/config.rb +61 -1
  38. data/lib/otto/security/core.rb +4 -1
  39. data/lib/otto/security/csp/report_middleware.rb +3 -1
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +228 -18
  41. data/lib/otto/security/rate_limiter.rb +7 -1
  42. data/lib/otto/utils.rb +100 -0
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +11 -3
  45. 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
- # This middleware runs FIRST in the stack to ensure all downstream
14
- # middleware and application code receives masked IPs by default.
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
- return @app.call(env) if env.key?('otto.client_ip')
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
- # This is the trusted-proxy *identity* check only it is deliberately
56
- # independent of count-based depth mode. Depth resolves the client IP;
57
- # it never grants proxy trust for X-Forwarded-Proto (matching the
58
- # downstream OneTimeSecret behavior).
59
- env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
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
- if @privacy_enabled
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
- Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
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(env, Otto::Privacy::RedactedFingerprint.new(env, @config))
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
- Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
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(env, @config)
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 an IP is from a trusted proxy
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
- env['otto.client_ip'] = resolve_client_ip(env)
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.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.6.0'
6
+ VERSION = '2.8.0'
7
7
  end
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
- # Add IP Privacy middleware first in stack (privacy by default for public IPs)
230
- # Private/localhost IPs are automatically exempted from masking
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: :first
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.6.0
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-10 00:00:00.000000000 Z
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