otto 2.10.0 → 2.12.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/.rubocop_todo.yml +3 -2
  10. data/AGENTS.md +18 -0
  11. data/CHANGELOG.rst +132 -0
  12. data/Gemfile +2 -2
  13. data/Gemfile.lock +8 -8
  14. data/README.md +6 -0
  15. data/docs/guides/configuration_freezing.md +17 -6
  16. data/docs/guides/forwarded-authority.md +5 -1
  17. data/docs/guides/privacy.md +5 -0
  18. data/docs/guides/routing.md +180 -5
  19. data/docs/guides/testing-guide.md +115 -2
  20. data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
  21. data/lib/otto/core/configuration.rb +9 -4
  22. data/lib/otto/core/error_handler.rb +40 -2
  23. data/lib/otto/core/file_safety.rb +24 -8
  24. data/lib/otto/core/router.rb +53 -24
  25. data/lib/otto/core/static_mounts.rb +172 -0
  26. data/lib/otto/core.rb +1 -0
  27. data/lib/otto/env_keys.rb +2 -1
  28. data/lib/otto/privacy/config.rb +19 -13
  29. data/lib/otto/response.rb +5 -2
  30. data/lib/otto/route.rb +1 -1
  31. data/lib/otto/route_handlers/base.rb +1 -1
  32. data/lib/otto/route_handlers/logic_class.rb +86 -34
  33. data/lib/otto/security/config.rb +349 -303
  34. data/lib/otto/security/configurator.rb +82 -45
  35. data/lib/otto/security/core.rb +4 -2
  36. data/lib/otto/security/csp/report_middleware.rb +17 -1
  37. data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
  38. data/lib/otto/security/rate_limiter.rb +8 -5
  39. data/lib/otto/security/trusted_proxy_config.rb +396 -0
  40. data/lib/otto/static.rb +45 -6
  41. data/lib/otto/testing.rb +148 -0
  42. data/lib/otto/utils.rb +63 -14
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +126 -2
  45. metadata +5 -2
data/lib/otto/utils.rb CHANGED
@@ -45,6 +45,14 @@ class Otto
45
45
  # fallback scan cannot drift.
46
46
  RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
47
47
 
48
+ # Headers the client-IP resolver may read an address from: the
49
+ # forwarded-for family (the CIDR walk, and X-Forwarded-For in depth mode)
50
+ # and RFC 7239 Forwarded (depth mode with trusted_proxy_header 'Forwarded'
51
+ # or 'Both'). A header resolve_client_ip starts reading belongs here, so
52
+ # that IPPrivacyMiddleware deletes it when no client IP resolves and
53
+ # Otto::Testing.env_for refuses it in a request it builds as direct.
54
+ CLIENT_ADDRESS_HEADERS = (FORWARDED_FOR_HEADERS + %w[HTTP_FORWARDED]).freeze
55
+
48
56
  # Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
49
57
  # predicates do not cover but that should still be treated as non-public
50
58
  # (e.g. when picking the real client out of a forwarded chain).
@@ -100,20 +108,21 @@ class Otto
100
108
  # scrub invalid/undefined bytes, and strip a single trailing slash.
101
109
  #
102
110
  # This is the SINGLE SOURCE OF TRUTH shared by the router
103
- # (Otto::Core::Router#handle_request, which compares the result against its
104
- # literal-route table) and Otto::CaddyTLS::LocalhostGuard (which compares it
105
- # against the guarded endpoint). The two MUST normalize identically: if a
106
- # crafted path — a trailing slash, a percent-encoded byte, an invalid UTF-8
107
- # byte — normalized differently in the guard than in the router, the router
108
- # could dispatch a request the guard let through, bypassing the loopback
109
- # check. One implementation makes that drift impossible.
110
- #
111
- # Mirrors the empty-path handling and :replace scrubbing the router applies.
112
- # Robust to invalid input: Rack::Utils.unescape raises ArgumentError on an
113
- # already-invalid byte sequence (a raw \xFF in the path), so that is caught
114
- # and the raw string is scrubbed instead — a percent-encoded invalid byte
115
- # (%FF) decodes to the same invalid byte and is scrubbed identically, so the
116
- # two crafted forms normalize alike. The method itself does not raise.
111
+ # (Otto::Core::Router#handle_request, through #routing_path) and every guard
112
+ # that compares a request path or a configured path against what the router
113
+ # dispatches (Otto::CaddyTLS::LocalhostGuard, Otto::MCP.endpoint_path?).
114
+ # For a request, call #routing_path rather than passing PATH_INFO here
115
+ # yourself. Guard and router MUST normalize identically: if a crafted
116
+ # path — a trailing slash, a percent-encoded byte, an invalid UTF-8 byte —
117
+ # normalized differently in the guard than in the router, the router could
118
+ # dispatch a request the guard let through. One implementation makes that
119
+ # drift impossible.
120
+ #
121
+ # Robust to invalid input. Rack::Utils.unescape raises ArgumentError on a
122
+ # malformed escape (%zz, a trailing %) and on an invalid byte in a
123
+ # UTF-8-tagged string (a raw \xFF); either way the raw string is kept.
124
+ # Invalid UTF-8 is scrubbed after that, so a raw \xFF and a percent-encoded
125
+ # %FF normalize alike. The method itself does not raise.
117
126
  #
118
127
  # @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
119
128
  # @return [String] normalized path suitable for exact literal comparison
@@ -131,6 +140,46 @@ class Otto
131
140
  .gsub(%r{/$}, '')
132
141
  end
133
142
 
143
+ # The path Otto's router matches for this request. Use it in any code that
144
+ # judges a request by its path before the router sees it: guards,
145
+ # throttles, session skips, audit filters.
146
+ #
147
+ # The router does not match raw PATH_INFO. It matches this value, and
148
+ # Otto::Core::Router#handle_request calls this method to get it, so a guard
149
+ # that reads routing_path sees the path the router dispatches on. A guard
150
+ # that reads anything else can see a different path, and when it matches
151
+ # less than the router does the difference is a bypass: GET /%63olonel is
152
+ # '/%63olonel' as raw PATH_INFO and '/colonel' to the router.
153
+ #
154
+ # By default the result is mount-relative. Rack::URLMap
155
+ # (`map '/api' { run otto }`) moves the mount prefix into SCRIPT_NAME and
156
+ # leaves the remainder in PATH_INFO, which is all the router sees; this is
157
+ # the form to compare against paths as written in a routes file.
158
+ #
159
+ # With +include_mount: true+ SCRIPT_NAME and PATH_INFO are joined and then
160
+ # normalized as one string, giving the request's full path. That is
161
+ # the form for middleware shared by several mounted apps and configured
162
+ # with external URLs: inside an app mounted at /api/v2, '/status' is the
163
+ # mount-relative path and '/api/v2/status' the mounted one, and matching
164
+ # the mount-relative form would also match every other app's /status.
165
+ #
166
+ # The value is normalize_path output, so root is '' (the router's literal
167
+ # table keys root the same way) and a configured path must go through
168
+ # normalize_path before an exact comparison. Never raises: a malformed
169
+ # escape such as %zz is kept as written.
170
+ #
171
+ # Not memoized: middleware may rewrite PATH_INFO or SCRIPT_NAME, and the
172
+ # router must see the value as it stands at dispatch.
173
+ #
174
+ # @param env [Hash] Rack environment
175
+ # @param include_mount [Boolean] prepend SCRIPT_NAME (the mount prefix)
176
+ # @return [String] normalized path
177
+ def routing_path(env, include_mount: false)
178
+ path = env['PATH_INFO']
179
+ path = "#{env['SCRIPT_NAME']}#{path}" if include_mount
180
+ normalize_path(path)
181
+ end
182
+
134
183
  # Validate and normalize an IP address (IPv4 and IPv6).
135
184
  #
136
185
  # Strips an optional port (IPv6-safe), validates with IPAddr, and returns
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.10.0'
6
+ VERSION = '2.12.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -55,6 +55,7 @@ require_relative 'otto/logging_helpers'
55
55
  class Otto
56
56
  include Otto::Core::Router
57
57
  include Otto::Core::FileSafety
58
+ include Otto::Core::StaticMounts
58
59
  include Otto::Core::Configuration
59
60
  include Otto::Core::ErrorHandler
60
61
  include Otto::Core::UriGenerator
@@ -68,6 +69,10 @@ class Otto
68
69
 
69
70
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
70
71
 
72
+ # Parameter types (from Proc#parameters / Method#parameters) that consume
73
+ # one positional argument each. See {#fallback_call_args}.
74
+ POSITIONAL_PARAMETER_TYPES = %i[req opt].freeze
75
+
71
76
  @debug = case ENV.fetch('OTTO_DEBUG', nil)
72
77
  in 'true' | '1' | 'yes' | 'on'
73
78
  true
@@ -80,8 +85,54 @@ class Otto
80
85
  :routes_by_definition, :option,
81
86
  :static_route, :security_config, :locale_config, :auth_config,
82
87
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
83
- :error_handlers, :request_class, :response_class
84
- attr_accessor :not_found, :server_error
88
+ :error_handlers, :request_class, :response_class,
89
+ :not_found, :server_error
90
+
91
+ # Configure the response returned when no route (and no +/404+ route) matches.
92
+ #
93
+ # Accepts either a Rack triple +[status, headers, body]+ (the body must
94
+ # respond to +each+, or +call+ for a streaming body) or anything that
95
+ # responds to +call(env)+ and returns one. A static triple is never handed
96
+ # back to the Rack stack by reference: Otto returns a per-request copy whose
97
+ # headers (and Array-valued header entries, and Array body) are fresh
98
+ # containers, so cookie middleware such as rack-session or Otto's own CSRF
99
+ # middleware cannot accumulate +Set-Cookie+ values on the configured object
100
+ # and replay them to later clients. Prefer the callable form when the
101
+ # response should vary per request.
102
+ #
103
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
104
+ # restore the built-in {Otto::Static.not_found} response
105
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
106
+ #
107
+ # @example Static triple (copied per request)
108
+ # otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
109
+ #
110
+ # @example Callable, built fresh on every miss
111
+ # otto.not_found = ->(env) { [404, { 'content-type' => 'text/plain' }, ["No #{env['PATH_INFO']}"]] }
112
+ def not_found=(response)
113
+ @not_found = validate_fallback_response!(:not_found, response)
114
+ end
115
+
116
+ # Configure the response returned for an unhandled error when no +/500+
117
+ # route is configured and the client does not prefer JSON.
118
+ #
119
+ # Accepts either a Rack triple or anything that responds to +call(env, error)+
120
+ # and returns one; a callable declaring a single positional parameter (or
121
+ # none) receives only what it declares. +env['otto.error_id']+ carries the
122
+ # logged correlation id. As with {#not_found=}, a static triple is copied
123
+ # per request so header writes by cookie middleware never touch the
124
+ # configured object. A callable that raises is logged and replaced by the
125
+ # built-in secure error response.
126
+ #
127
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
128
+ # restore the built-in secure error response
129
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
130
+ #
131
+ # @example Callable receiving the error
132
+ # otto.server_error = ->(env, error) { [500, { 'content-type' => 'text/plain' }, ['Oops']] }
133
+ def server_error=(response)
134
+ @server_error = validate_fallback_response!(:server_error, response)
135
+ end
85
136
 
86
137
  def initialize(path = nil, opts = {})
87
138
  constructed = false
@@ -188,6 +239,10 @@ class Otto
188
239
  # so reverse lookups (Otto#uri) consult this index instead of the
189
240
  # single-route @route_definitions entry (issue #190).
190
241
  @routes_by_definition = {}
242
+ # Explicit static mounts (Core::StaticMounts#mount_static). Always a
243
+ # frozen snapshot, replaced wholesale on registration; dispatch reads it
244
+ # without locking.
245
+ @static_mounts = [].freeze
191
246
  @security_config = Otto::Security::Config.new
192
247
  @middleware = Otto::Core::MiddlewareStack.new
193
248
  # Initialize @auth_config first so it can be shared with the configurator
@@ -301,6 +356,75 @@ class Otto
301
356
  end
302
357
  end
303
358
 
359
+ # Validate a value assigned to {#not_found=} or {#server_error=}.
360
+ #
361
+ # @param name [Symbol] the setting name, for the error message
362
+ # @param response [Object] the assigned value
363
+ # @return [Array, #call, nil] the value, when acceptable
364
+ # @raise [ArgumentError] otherwise
365
+ def validate_fallback_response!(name, response)
366
+ return response if response.nil? || response.respond_to?(:call)
367
+ return response if rack_triple?(response)
368
+
369
+ raise ArgumentError,
370
+ "#{name} must be a Rack triple [status, headers, body] or respond to #call, got #{response.inspect}"
371
+ end
372
+
373
+ # A Rack triple: an Integer-like status, Hash-like headers, and a body that
374
+ # responds to +each+ (or +call+, for a streaming body).
375
+ def rack_triple?(response)
376
+ return false unless response.is_a?(Array) && response.length == 3
377
+
378
+ status, headers, body = response
379
+ status.respond_to?(:to_int) && headers.respond_to?(:each_pair) &&
380
+ (body.respond_to?(:each) || body.respond_to?(:call))
381
+ end
382
+
383
+ # Resolve a configured fallback into a fresh Rack triple for one request.
384
+ #
385
+ # A callable is invoked with as many of +args+ as it accepts (see
386
+ # {#fallback_call_args}); a static triple is used as-is. Either way the
387
+ # result is copied (see {Otto::Static.copy_response}) so the Rack stack
388
+ # never receives a container shared with the configuration or with another
389
+ # request. Configured security headers then fill any fields the fallback did
390
+ # not set explicitly.
391
+ #
392
+ # @param name [Symbol] the setting name, for the error message
393
+ # @param fallback [Array, #call] the configured value
394
+ # @param args [Array] positional arguments offered to a callable fallback
395
+ # @return [Array] a new Rack triple
396
+ # @raise [TypeError] when a callable returns something other than a Rack triple
397
+ def resolve_fallback_response(name, fallback, *args)
398
+ response = fallback.respond_to?(:call) ? fallback.call(*fallback_call_args(fallback, args)) : fallback
399
+ unless rack_triple?(response)
400
+ raise TypeError,
401
+ "#{name} callable must return a Rack triple [status, headers, body], got #{response.inspect}"
402
+ end
403
+
404
+ copied = Otto::Static.copy_response(response)
405
+ @security_config.security_headers.each_pair do |header, value|
406
+ copied[1][header] = value unless copied[1].key?(header)
407
+ end
408
+ copied
409
+ end
410
+
411
+ # Trim +args+ to the positional parameters +callable+ declares, so a lambda
412
+ # or Method that takes fewer (or optional) parameters is never handed an
413
+ # argument it would reject. A splat parameter receives everything. Arity
414
+ # alone cannot express this: +->(env = nil) {}+ has arity -1, the same as
415
+ # +proc { |*a| }+, yet accepts at most one argument.
416
+ #
417
+ # @param callable [#call]
418
+ # @param args [Array] the arguments on offer, in order
419
+ # @return [Array] the leading subset of +args+ the callable accepts
420
+ def fallback_call_args(callable, args)
421
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
422
+ return args if params.any? { |type, _name| type == :rest }
423
+
424
+ args.first(params.count { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) })
425
+ end
426
+ private :validate_fallback_response!, :rack_triple?, :resolve_fallback_response, :fallback_call_args
427
+
304
428
  # Class methods for Otto framework providing singleton access and configuration
305
429
  module ClassMethods
306
430
  def default
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.10.0
4
+ version: 2.12.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-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -223,6 +223,7 @@ files:
223
223
  - lib/otto/core/middleware_management.rb
224
224
  - lib/otto/core/middleware_stack.rb
225
225
  - lib/otto/core/router.rb
226
+ - lib/otto/core/static_mounts.rb
226
227
  - lib/otto/core/uri_generator.rb
227
228
  - lib/otto/design_system.rb
228
229
  - lib/otto/env_keys.rb
@@ -313,8 +314,10 @@ files:
313
314
  - lib/otto/security/middleware/validation_middleware.rb
314
315
  - lib/otto/security/rate_limiter.rb
315
316
  - lib/otto/security/rate_limiting.rb
317
+ - lib/otto/security/trusted_proxy_config.rb
316
318
  - lib/otto/security/validator.rb
317
319
  - lib/otto/static.rb
320
+ - lib/otto/testing.rb
318
321
  - lib/otto/utils.rb
319
322
  - lib/otto/version.rb
320
323
  - otto.gemspec