otto 2.5.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 (67) 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 +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  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/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
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.5.0'
6
+ VERSION = '2.7.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent'
5
6
  require 'json'
6
7
  require 'logger'
7
8
  require 'securerandom'
@@ -75,7 +76,8 @@ class Otto
75
76
  end
76
77
  @logger = Logger.new($stdout, Logger::INFO)
77
78
 
78
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
79
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
80
+ :routes_by_definition, :option,
79
81
  :static_route, :security_config, :locale_config, :auth_config,
80
82
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
81
83
  :error_handlers, :request_class, :response_class
@@ -107,6 +109,10 @@ class Otto
107
109
  end
108
110
  alias options option
109
111
 
112
+ # Read-only view of assembled instance options. LambdaHandler resolves its
113
+ # registry via otto_instance.config[:lambda_handlers].
114
+ alias config option
115
+
110
116
  # Main Rack application interface
111
117
  def call(env)
112
118
  # Freeze configuration on first request (thread-safe).
@@ -166,10 +172,24 @@ class Otto
166
172
  private
167
173
 
168
174
  def initialize_core_state
169
- @routes_static = { GET: {} }
175
+ # The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
176
+ # discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
177
+ # writes into it at request time, after freeze_configuration! has already
178
+ # deep-frozen the rest of the routing state. Deep-freezing this cache too
179
+ # would turn every as-yet-uncached static file request into a 500
180
+ # (FrozenError) in production (issue #185), so it is intentionally excluded
181
+ # from deep_freeze_value in Configuration#freeze_configuration! and kept as
182
+ # a structure that is both mutable post-freeze and safe under concurrent
183
+ # request threads.
184
+ @routes_static = { GET: Concurrent::Map.new }
170
185
  @routes = { GET: [] }
171
186
  @routes_literal = { GET: {} }
172
187
  @route_definitions = {}
188
+ # All routes per definition string, in load order. A definition string is
189
+ # not unique — the same handler can be mounted at several verb/path pairs —
190
+ # so reverse lookups (Otto#uri) consult this index instead of the
191
+ # single-route @route_definitions entry (issue #190).
192
+ @routes_by_definition = {}
173
193
  @security_config = Otto::Security::Config.new
174
194
  @middleware = Otto::Core::MiddlewareStack.new
175
195
  # Initialize @auth_config first so it can be shared with the configurator
@@ -206,11 +226,19 @@ class Otto
206
226
  # before first request (before configuration freezing)
207
227
  finalize_request_response_classes
208
228
 
209
- # Add IP Privacy middleware first in stack (privacy by default for public IPs)
210
- # 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.
211
239
  @middleware.add_with_position(
212
240
  Otto::Security::Middleware::IPPrivacyMiddleware,
213
- position: :first
241
+ position: :entrypoint
214
242
  )
215
243
  end
216
244
 
@@ -234,6 +262,10 @@ class Otto
234
262
 
235
263
  # Initialize MCP server
236
264
  configure_mcp(opts)
265
+
266
+ # Validate and freeze the lambda handler registry (issue #41).
267
+ # Runs last so misconfiguration fails fast at construction.
268
+ configure_lambda_handlers(opts)
237
269
  end
238
270
 
239
271
  class << self
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.5.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-03 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
@@ -203,6 +205,10 @@ files:
203
205
  - examples/caddy_tls_demo/routes
204
206
  - examples/caddy_tls_demo/standalone.ru
205
207
  - examples/error_handler_registration.rb
208
+ - examples/lambda_handlers/README.md
209
+ - examples/lambda_handlers/config.ru
210
+ - examples/lambda_handlers/handlers.rb
211
+ - examples/lambda_handlers/routes
206
212
  - examples/logging_improvements.rb
207
213
  - examples/mcp_demo/README.md
208
214
  - examples/mcp_demo/app.rb
@@ -254,6 +260,7 @@ files:
254
260
  - lib/otto/privacy/geo_resolver.rb
255
261
  - lib/otto/privacy/ip_privacy.rb
256
262
  - lib/otto/privacy/redacted_fingerprint.rb
263
+ - lib/otto/privacy/user_agent_privacy.rb
257
264
  - lib/otto/request.rb
258
265
  - lib/otto/response.rb
259
266
  - lib/otto/response_handlers.rb
@@ -302,6 +309,8 @@ files:
302
309
  - lib/otto/security/csp/report_middleware.rb
303
310
  - lib/otto/security/csp/writer.rb
304
311
  - lib/otto/security/csrf.rb
312
+ - lib/otto/security/csrf_enforcement_wrapper.rb
313
+ - lib/otto/security/csrf_validation.rb
305
314
  - lib/otto/security/middleware/csrf_middleware.rb
306
315
  - lib/otto/security/middleware/ip_privacy_middleware.rb
307
316
  - lib/otto/security/middleware/rate_limit_middleware.rb
@@ -320,7 +329,6 @@ licenses:
320
329
  - MIT
321
330
  metadata:
322
331
  rubygems_mfa_required: 'true'
323
- post_install_message:
324
332
  rdoc_options: []
325
333
  require_paths:
326
334
  - lib
@@ -338,8 +346,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
338
346
  - !ruby/object:Gem::Version
339
347
  version: '0'
340
348
  requirements: []
341
- rubygems_version: 3.5.22
342
- signing_key:
349
+ rubygems_version: 3.6.9
343
350
  specification_version: 4
344
351
  summary: Define your rack-apps in plaintext.
345
352
  test_files: []