otto 2.9.0 → 2.10.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -36,6 +36,14 @@ class Otto
36
36
  # # env['otto.original_ip'] also contains real IP
37
37
  #
38
38
  class IPPrivacyMiddleware
39
+ # Forwarding metadata Rack::Request reads without consulting Otto's
40
+ # proxy trust verdict: host (#host/#authority), scheme (#scheme/#ssl?),
41
+ # and port (#port), from both the X-Forwarded-* family and RFC 7239
42
+ # Forwarded (host=, proto=, and the port inside for=). Defined in
43
+ # Otto::Utils so Otto::Utils::RELAY_MARKER_HEADERS is built from the
44
+ # same list and cannot fall out of step with what is scrubbed here.
45
+ UNTRUSTED_FORWARDING_METADATA_HEADERS = Otto::Utils::FORWARDED_AUTHORITY_HEADERS
46
+
39
47
  # Initialize IP Privacy middleware
40
48
  #
41
49
  # @param app [#call] Rack application
@@ -55,8 +63,13 @@ class Otto
55
63
  # canonical client IP for this request, do not re-resolve or re-mask.
56
64
  # This makes stacking two instances (e.g. an app-level mount plus
57
65
  # Otto's built-in router mount) order-safe instead of double-masking.
66
+ #
67
+ # Client-IP resolution is idempotent, but proxy TRUST is not: the
68
+ # prior pass may have run under a different (or no) configuration,
69
+ # so this instance still enforces its own trust posture below.
58
70
  if env.key?('otto.client_ip')
59
71
  ensure_ip_match_present(env)
72
+ enforce_proxy_trust_after_prior_pass(env)
60
73
  return @app.call(env)
61
74
  end
62
75
 
@@ -65,7 +78,9 @@ class Otto
65
78
  # REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
66
79
  #
67
80
  # TRI-STATE: the key is written ONLY when the operator configured
68
- # proxy trust (CIDR matchers or a depth). Present, its value is
81
+ # proxy trust (CIDR matchers, a depth, or the explicit trust-nobody
82
+ # assertion `trusted_proxies: :none`, which makes the value false for
83
+ # every peer, #259). Present, its value is
69
84
  # authoritative in both directions — true means the peer matched a
70
85
  # CIDR (filter mode) or depth mode is active (configuring a depth
71
86
  # asserts the connecting peer IS the operator's proxy tier, #226);
@@ -77,9 +92,24 @@ class Otto
77
92
  # forced consumers into grant-only reads (#228).
78
93
  # respond_to?: like geo_headers_trusted?, a partial/duck-typed
79
94
  # 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
95
+ proxy_trust_configured = @security_config.respond_to?(:proxy_trust_configured?) &&
96
+ @security_config.proxy_trust_configured?
97
+ via_trusted_proxy = proxy_trust_configured && trusted_proxy?(env['REMOTE_ADDR'])
98
+ env['otto.via_trusted_proxy'] = via_trusted_proxy if proxy_trust_configured
99
+
100
+ # Record whether the request was relayed BEFORE the scrub below can
101
+ # delete the relay markers. Downstream middleware that must
102
+ # authenticate a direct local call (Otto::CaddyTLS::LocalhostGuard)
103
+ # runs after this one and would otherwise read a request whose
104
+ # HTTP_FORWARDED was deleted here as "direct". Leak-free boolean.
105
+ env['otto.peer_relayed'] = Otto::Utils.relayed_request?(env)
106
+
107
+ # A peer that failed configured proxy trust cannot supply forwarded
108
+ # host, scheme, or port either. Unconfigured trust (absent tri-state
109
+ # key) is left alone: the operator has asserted nothing, and the
110
+ # contract reserves that state for downstream heuristics (#228), so
111
+ # Rack keeps its own defaults there.
112
+ scrub_untrusted_forwarding_metadata(env) if proxy_trust_configured && !via_trusted_proxy
83
113
 
84
114
  # Same rationale, for loopback: this middleware runs outermost, so a
85
115
  # downstream middleware that must authenticate a DIRECT LOCAL CALL
@@ -121,6 +151,58 @@ class Otto
121
151
  @config.enabled?
122
152
  end
123
153
 
154
+ # Enforce THIS instance's proxy trust posture when a prior
155
+ # IPPrivacyMiddleware pass already resolved the client IP.
156
+ #
157
+ # The early return in #call keeps client-IP resolution idempotent, but
158
+ # trust enforcement must not ride on it: an outer instance mounted
159
+ # without the application's security config (the "unconfigured
160
+ # defaults" case in docs/guides/privacy.md) writes otto.client_ip and
161
+ # nothing else, and returning here would let an inner
162
+ # `trusted_proxies: :none` instance pass X-Forwarded-Host through
163
+ # untouched — the exact bypass the assertion exists to close.
164
+ #
165
+ # What can still be decided after the prior pass:
166
+ # - trust-nobody: the verdict is false for every peer, so it needs no
167
+ # peer address. Always enforced, overriding a laxer prior verdict.
168
+ # - depth mode: the verdict is true by assertion. Recorded only when
169
+ # no configured pass has spoken (key absent).
170
+ # - CIDR mode: needs the connecting peer, which the prior pass has
171
+ # rewritten (REMOTE_ADDR is now the resolved, possibly masked, client
172
+ # IP — matching a masked address against a narrow CIDR produces false
173
+ # ALLOWs, see #ensure_ip_match_present). A verdict recorded by a prior
174
+ # CONFIGURED pass is kept (the same-config stacking case). With none,
175
+ # fail closed: deny, scrub, and warn so the misconfiguration is
176
+ # diagnosable rather than a silent grant.
177
+ #
178
+ # Every deny also scrubs the authority carriers, which is idempotent:
179
+ # deleting an already-deleted key is a no-op.
180
+ #
181
+ # @param env [Hash] Rack environment
182
+ def enforce_proxy_trust_after_prior_pass(env)
183
+ return unless @security_config.respond_to?(:proxy_trust_configured?) &&
184
+ @security_config.proxy_trust_configured?
185
+
186
+ if @security_config.trust_no_proxies?
187
+ env['otto.via_trusted_proxy'] = false
188
+ scrub_untrusted_forwarding_metadata(env)
189
+ elsif env.key?('otto.via_trusted_proxy')
190
+ nil # a configured pass already decided; honor it
191
+ elsif @security_config.trusted_proxy_depth_mode?
192
+ env['otto.via_trusted_proxy'] = true
193
+ else
194
+ Otto.logger.warn(
195
+ '[IPPrivacyMiddleware] otto.client_ip was resolved by a prior ' \
196
+ 'pass with no proxy trust configured, so the connecting peer ' \
197
+ 'can no longer be matched against trusted_proxies; treating ' \
198
+ 'the peer as untrusted and stripping forwarded authority. ' \
199
+ 'Pass the application security config to the outer instance.'
200
+ )
201
+ env['otto.via_trusted_proxy'] = false
202
+ scrub_untrusted_forwarding_metadata(env)
203
+ end
204
+ end
205
+
124
206
  # Guarantee env['otto.ip_match'] exists on the idempotent-return path.
125
207
  #
126
208
  # Every path in this middleware that sets otto.client_ip installs the
@@ -389,6 +471,25 @@ class Otto
389
471
  env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
390
472
  end
391
473
 
474
+ # Remove forwarding metadata supplied by a peer that failed proxy trust.
475
+ #
476
+ # Rack honors these according only to the process-global forwarding
477
+ # family, so without this an untrusted client would control
478
+ # request.host, request.ssl?, and request.port for every mounted Rack
479
+ # app (Rack::Session's Secure-cookie gate reads ssl?). Delete the
480
+ # carriers rather than editing them: this path runs only in CIDR filter
481
+ # mode or under the trust-nobody assertion (`trusted_proxies: :none`,
482
+ # #259), neither of which reads the Forwarded header itself, and a
483
+ # hand-rolled RFC 7239 parser that disagrees with Rack's on quoting
484
+ # (e.g. `for=a"b;host=evil`) would let a host= survive the edit.
485
+ # X-Forwarded-For stays, masked or not, because Otto's own resolution
486
+ # already ignores it from an untrusted peer.
487
+ #
488
+ # @param env [Hash] Rack environment
489
+ def scrub_untrusted_forwarding_metadata(env)
490
+ UNTRUSTED_FORWARDING_METADATA_HEADERS.each { |key| env.delete(key) }
491
+ end
492
+
392
493
  # Delete forwarded IP headers outright.
393
494
  #
394
495
  # Used on the no-resolvable-client-IP path, where there is no masked IP
@@ -27,13 +27,8 @@ class Otto
27
27
  def initialize(app, security_config = nil)
28
28
  @app = app
29
29
  @security_config = security_config
30
- @rate_limiter_available = defined?(Rack::Attack)
31
30
 
32
- if @rate_limiter_available
33
- configure_rate_limiting
34
- else
35
- Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
36
- end
31
+ configure_rate_limiting
37
32
  end
38
33
 
39
34
  # Pass-through call - actual rate limiting handled by Rack::Attack
@@ -4,18 +4,25 @@
4
4
 
5
5
  require 'json'
6
6
 
7
- begin
8
- require 'rack/attack'
9
- rescue LoadError
10
- # rack-attack is optional - graceful fallback
11
- end
7
+ require_relative '../optional_dependency'
12
8
 
13
9
  class Otto
14
10
  module Security
15
11
  # Rate limiting implementation using Rack::Attack
16
12
  class RateLimiting
13
+ RACK_ATTACK_REQUIREMENT = '~> 6.7'
14
+
15
+ def self.ensure_available!
16
+ Otto::OptionalDependency.require!(
17
+ 'rack-attack',
18
+ RACK_ATTACK_REQUIREMENT,
19
+ require_path: 'rack/attack',
20
+ feature: 'Rate limiting'
21
+ )
22
+ end
23
+
17
24
  def self.configure_rack_attack!(config = {})
18
- return unless defined?(Rack::Attack)
25
+ ensure_available!
19
26
 
20
27
  # Use provided cache store or default
21
28
  Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
@@ -23,9 +30,13 @@ class Otto
23
30
  # Default rules
24
31
  default_requests_per_minute = config.fetch(:requests_per_minute, 100)
25
32
 
26
- # General request throttling
33
+ # General request throttling. Internal paths (/_mcp, /_status, ...)
34
+ # are skipped by default. PATH_INFO, not Rack::Request#path: #path
35
+ # prepends SCRIPT_NAME, so with Otto mounted under `map '/api'` the
36
+ # internal /_mcp request read as /api/_mcp and was counted here while
37
+ # the MCP throttle, comparing the same way, never saw it at all.
27
38
  Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
28
- request.ip unless request.path.start_with?('/_') # Skip internal paths by default
39
+ request.ip unless request.path_info.start_with?('/_')
29
40
  end
30
41
 
31
42
  # Apply custom rules if provided
@@ -45,52 +56,76 @@ class Otto
45
56
  end
46
57
  end
47
58
 
48
- # Custom response for rate limited requests
49
- Rack::Attack.throttled_responder = lambda do |request|
50
- match_data = request.env['rack.attack.match_data']
51
- now = match_data[:epoch_time]
52
-
53
- headers = {
54
- 'content-type' => 'application/json',
55
- 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
- }
59
+ configure_responses
60
+ configure_logging
61
+ end
57
62
 
58
- # Content negotiation for rate limit response
59
- # Route's response_type takes precedence over Accept header
60
- route_def = request.env['otto.route_definition']
61
- wants_json = (route_def&.response_type == 'json') ||
62
- request.env['HTTP_ACCEPT'].to_s.include?('application/json')
63
-
64
- if wants_json
65
- error_response = {
66
- error: 'Rate limit exceeded',
67
- message: 'Too many requests',
68
- retry_after: headers['retry-after'].to_i,
69
- limit: match_data[:limit],
70
- period: match_data[:period],
71
- }
72
- [429, headers, [JSON.generate(error_response)]]
73
- else
74
- body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
75
- headers['content-type'] = 'text/plain'
76
- [429, headers, [body]]
77
- end
78
- end
63
+ # Rack::Attack holds a single throttled_responder and each subscriber to
64
+ # 'rack.attack' fires on every throttle event. Subclasses override
65
+ # throttled_response and log_throttled_request instead of registering
66
+ # their own responder or subscriber after super, so the hosting app never
67
+ # gets a redundant responder assignment or doubled log lines.
68
+ def self.configure_responses
69
+ Rack::Attack.throttled_responder = ->(request) { throttled_response(request) }
70
+ end
79
71
 
72
+ def self.configure_logging
80
73
  # Log blocked requests if ActiveSupport is available
81
74
  return unless defined?(ActiveSupport::Notifications)
82
75
 
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).
88
76
  ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
89
- req = payload[:request]
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]}"
77
+ log_throttled_request(payload)
78
+ end
79
+ end
80
+
81
+ def self.throttled_response(request)
82
+ match_data = request.env['rack.attack.match_data']
83
+ general_throttled_response(request, throttle_headers(match_data), match_data)
84
+ end
85
+
86
+ def self.throttle_headers(match_data)
87
+ now = match_data[:epoch_time]
88
+
89
+ {
90
+ 'content-type' => 'application/json',
91
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
92
+ }
93
+ end
94
+
95
+ # Custom response for rate limited requests
96
+ def self.general_throttled_response(request, headers, match_data)
97
+ # Content negotiation for rate limit response
98
+ # Route's response_type takes precedence over Accept header
99
+ route_def = request.env['otto.route_definition']
100
+ wants_json = (route_def&.response_type == 'json') ||
101
+ request.env['HTTP_ACCEPT'].to_s.include?('application/json')
102
+
103
+ if wants_json
104
+ error_response = {
105
+ error: 'Rate limit exceeded',
106
+ message: 'Too many requests',
107
+ retry_after: headers['retry-after'].to_i,
108
+ limit: match_data[:limit],
109
+ period: match_data[:period],
110
+ }
111
+ [429, headers, [JSON.generate(error_response)]]
112
+ else
113
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
114
+ headers['content-type'] = 'text/plain'
115
+ [429, headers, [body]]
92
116
  end
93
117
  end
118
+
119
+ # Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
120
+ # subscriber sees the raw peer regardless of where IPPrivacyMiddleware
121
+ # sits in Otto's own stack. Log a masked address, never req.ip: a
122
+ # deployment on the default :masked profile must not write raw client
123
+ # IPs to its logs every time a limit trips (issue #219).
124
+ def self.log_throttled_request(payload)
125
+ req = payload[:request]
126
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
127
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
128
+ end
94
129
  end
95
130
  end
96
131
  end
data/lib/otto/utils.rb CHANGED
@@ -19,9 +19,47 @@ class Otto
19
19
  HTTP_X_CLIENT_IP
20
20
  ].freeze
21
21
 
22
+ # Forwarding metadata Rack::Request reads without consulting Otto's proxy
23
+ # trust verdict: host (#host/#authority), scheme (#scheme/#ssl?), and port
24
+ # (#port), from both the X-Forwarded-* family and RFC 7239 Forwarded
25
+ # (host=, proto=, and the port inside for=). IPPrivacyMiddleware deletes
26
+ # every one of these for a peer that failed configured proxy trust.
27
+ FORWARDED_AUTHORITY_HEADERS = %w[
28
+ HTTP_FORWARDED
29
+ HTTP_X_FORWARDED_HOST
30
+ HTTP_X_FORWARDED_PROTO
31
+ HTTP_X_FORWARDED_SCHEME
32
+ HTTP_X_FORWARDED_SSL
33
+ HTTP_X_FORWARDED_PORT
34
+ ].freeze
35
+
36
+ # Headers whose presence means the request was RELAYED by a proxy rather
37
+ # than issued directly by the peer: every forwarding carrier Otto knows —
38
+ # the forwarded-for family, RFC 7239 Forwarded, and the authority (host /
39
+ # scheme / port) carriers. The set must cover everything IPPrivacyMiddleware
40
+ # may DELETE on the untrusted-peer path: a carrier that is scrubbed but not
41
+ # counted here would let a relayed request look direct afterwards. Shared
42
+ # by IPPrivacyMiddleware (which records the verdict as
43
+ # env['otto.peer_relayed'] BEFORE the scrub) and
44
+ # Otto::CaddyTLS::LocalhostGuard, so the record and the guard's own
45
+ # fallback scan cannot drift.
46
+ RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
47
+
22
48
  # Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
23
49
  # predicates do not cover but that should still be treated as non-public
24
50
  # (e.g. when picking the real client out of a forwarded chain).
51
+ #
52
+ # The documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24,
53
+ # 2001:db8::/32 and the newer 3fff::/20) are deliberately NOT listed here.
54
+ # The predicate answers "could this be the real client", and non-public
55
+ # entries are skipped as proxy hops. Those ranges are not globally routable,
56
+ # but they are the universal convention for an example public client in tests
57
+ # and docs, including this repo's own specs and guides, so treating them as
58
+ # non-public would make the resolver skip the example client and silently
59
+ # invalidate those examples. There is no security gain either: an attacker who
60
+ # can inject into a chain injects a routable address, and a documentation
61
+ # address in a real chain means a misconfigured hop, which is better surfaced
62
+ # than skipped.
25
63
  SPECIAL_USE_RANGES = [
26
64
  IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
27
65
  IPAddr.new('224.0.0.0/4'), # IPv4 multicast
@@ -389,6 +427,18 @@ class Otto
389
427
  end
390
428
  end
391
429
 
430
+ # Whether any relay marker header is present on the request.
431
+ #
432
+ # Call this only from code that runs BEFORE forwarding carriers may be
433
+ # deleted (IPPrivacyMiddleware's untrusted-peer scrub); downstream code
434
+ # should prefer the recorded env['otto.peer_relayed'] boolean.
435
+ #
436
+ # @param env [Hash] Rack environment
437
+ # @return [Boolean]
438
+ def relayed_request?(env)
439
+ RELAY_MARKER_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
440
+ end
441
+
392
442
  # Whether an address is on the loopback interface.
393
443
  #
394
444
  # This is the RAW SOCKET PEER test used to authenticate a direct local call
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.9.0'
6
+ VERSION = '2.10.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -76,7 +76,7 @@ class Otto
76
76
  end
77
77
  @logger = Logger.new($stdout, Logger::INFO)
78
78
 
79
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
79
+ attr_reader :routes, :routes_literal, :route_definitions,
80
80
  :routes_by_definition, :option,
81
81
  :static_route, :security_config, :locale_config, :auth_config,
82
82
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
@@ -84,6 +84,7 @@ class Otto
84
84
  attr_accessor :not_found, :server_error
85
85
 
86
86
  def initialize(path = nil, opts = {})
87
+ constructed = false
87
88
  initialize_core_state
88
89
  initialize_options(path, opts)
89
90
  initialize_configurations(opts)
@@ -106,6 +107,13 @@ class Otto
106
107
  # but before processing requests.
107
108
  @freeze_mutex = Mutex.new
108
109
  @configuration_frozen = false
110
+ constructed = true
111
+ ensure
112
+ # A config that committed to a forwarding family during configure_security
113
+ # is held process-wide; withdraw it if construction failed afterwards
114
+ # (e.g. a bad routes path), or the dead app would keep vetoing other
115
+ # families for the life of the process.
116
+ Otto::Security::Config.release_rack_forwarding_family!(@security_config) if @security_config && !constructed
109
117
  end
110
118
  alias options option
111
119
 
@@ -172,16 +180,6 @@ class Otto
172
180
  private
173
181
 
174
182
  def initialize_core_state
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 }
185
183
  @routes = { GET: [] }
186
184
  @routes_literal = { GET: {} }
187
185
  @route_definitions = {}
data/otto.gemspec CHANGED
@@ -34,8 +34,6 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency 'logger', '~> 1', '< 2.0'
35
35
 
36
36
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
37
- spec.add_dependency 'rack-parser', '~> 0.7'
38
- spec.add_dependency 'rexml', '~> 3.4'
39
37
 
40
38
  # Security dependencies
41
39
  spec.add_dependency 'loofah', '~> 2.20'
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.9.0
4
+ version: 2.10.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-08-19 00:00:00.000000000 Z
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -70,34 +70,6 @@ dependencies:
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
72
  version: '4.0'
73
- - !ruby/object:Gem::Dependency
74
- name: rack-parser
75
- requirement: !ruby/object:Gem::Requirement
76
- requirements:
77
- - - "~>"
78
- - !ruby/object:Gem::Version
79
- version: '0.7'
80
- type: :runtime
81
- prerelease: false
82
- version_requirements: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '0.7'
87
- - !ruby/object:Gem::Dependency
88
- name: rexml
89
- requirement: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '3.4'
94
- type: :runtime
95
- prerelease: false
96
- version_requirements: !ruby/object:Gem::Requirement
97
- requirements:
98
- - - "~>"
99
- - !ruby/object:Gem::Version
100
- version: '3.4'
101
73
  - !ruby/object:Gem::Dependency
102
74
  name: loofah
103
75
  requirement: !ruby/object:Gem::Requirement
@@ -144,18 +116,32 @@ files:
144
116
  - bin/rspec
145
117
  - changelog.d/README.md
146
118
  - changelog.d/scriv.ini
147
- - docs/.gitignore
148
- - docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
149
- - docs/1108-STREAMING_SUPPORT_SUMMARY.md
150
- - docs/enrichment.md
151
- - docs/geo-country.md
152
- - docs/ipaddr-encoding-quirk.md
153
- - docs/migrating/v2.0.0-pre1.md
154
- - docs/migrating/v2.0.0-pre2.md
119
+ - docs/README.md
120
+ - docs/adr/README.md
121
+ - docs/adr/adr-001-route-authentication-at-handler-boundary.md
122
+ - docs/adr/adr-002-multi-strategy-authentication-and-authorization.md
123
+ - docs/adr/adr-003-caddy-tls-route-based-integration.md
124
+ - docs/adr/adr-004-separate-compatibility-from-security-maintenance.md
125
+ - docs/guides/authentication.md
126
+ - docs/guides/caddy-tls.md
127
+ - docs/guides/configuration_freezing.md
128
+ - docs/guides/enrichment.md
129
+ - docs/guides/forwarded-authority.md
130
+ - docs/guides/geo-country.md
131
+ - docs/guides/ip_privacy.md
132
+ - docs/guides/ipaddr-encoding-quirk.md
133
+ - docs/guides/mcp.md
134
+ - docs/guides/privacy.md
135
+ - docs/guides/routing.md
136
+ - docs/guides/structured_logging.md
137
+ - docs/guides/testing-guide.md
138
+ - docs/maintainers/github-actions.md
139
+ - docs/maintainers/investigations/.gitignore
140
+ - docs/migrating/v2.0.0.md
155
141
  - docs/migrating/v2.3.0.md
156
- - docs/modern-authentication-authorization-landscape.md
157
- - docs/multi-strategy-authentication-design.md
158
- - docs/reverse-proxy-network-services.md
142
+ - docs/reference/authentication.md
143
+ - docs/reference/route-syntax.md
144
+ - docs/reference/runtime-and-dependency-security.md
159
145
  - examples/.gitignore
160
146
  - examples/advanced_routes/README.md
161
147
  - examples/advanced_routes/app.rb
@@ -251,12 +237,16 @@ files:
251
237
  - lib/otto/mcp.rb
252
238
  - lib/otto/mcp/auth/token.rb
253
239
  - lib/otto/mcp/core.rb
240
+ - lib/otto/mcp/endpoint.rb
241
+ - lib/otto/mcp/errors.rb
242
+ - lib/otto/mcp/options.rb
254
243
  - lib/otto/mcp/protocol.rb
255
244
  - lib/otto/mcp/rate_limiting.rb
256
245
  - lib/otto/mcp/registry.rb
257
246
  - lib/otto/mcp/route_parser.rb
258
247
  - lib/otto/mcp/schema_validation.rb
259
248
  - lib/otto/mcp/server.rb
249
+ - lib/otto/optional_dependency.rb
260
250
  - lib/otto/privacy.rb
261
251
  - lib/otto/privacy/anonymizer_resolver.rb
262
252
  - lib/otto/privacy/asn_resolver.rb
data/docs/.gitignore DELETED
@@ -1,10 +0,0 @@
1
- *
2
- !.gitignore
3
- !migrating/
4
- !migrating/*.md
5
- !ipaddr-encoding-quirk.md
6
- !enrichment.md
7
- !geo-country.md
8
- !modern-authentication-authorization-landscape.md
9
- !multi-strategy-authentication-design.md
10
- !reverse-proxy-network-services.md