otto 2.6.0 → 2.7.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 (44) 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 +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  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 +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -6
@@ -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.7.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,13 @@
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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -144,6 +143,9 @@ files:
144
143
  - changelog.d/README.md
145
144
  - changelog.d/scriv.ini
146
145
  - docs/.gitignore
146
+ - docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
147
+ - docs/1108-STREAMING_SUPPORT_SUMMARY.md
148
+ - docs/geo-country.md
147
149
  - docs/ipaddr-encoding-quirk.md
148
150
  - docs/migrating/v2.0.0-pre1.md
149
151
  - docs/migrating/v2.0.0-pre2.md
@@ -327,7 +329,6 @@ licenses:
327
329
  - MIT
328
330
  metadata:
329
331
  rubygems_mfa_required: 'true'
330
- post_install_message:
331
332
  rdoc_options: []
332
333
  require_paths:
333
334
  - lib
@@ -345,8 +346,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
345
346
  - !ruby/object:Gem::Version
346
347
  version: '0'
347
348
  requirements: []
348
- rubygems_version: 3.5.22
349
- signing_key:
349
+ rubygems_version: 3.6.9
350
350
  specification_version: 4
351
351
  summary: Define your rack-apps in plaintext.
352
352
  test_files: []