otto 2.8.1 → 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 (102) 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 +239 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +139 -38
  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 +42 -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/response.rb +7 -1
  70. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  71. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  72. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  76. data/lib/otto/security/config.rb +379 -20
  77. data/lib/otto/security/configurator.rb +36 -6
  78. data/lib/otto/security/core.rb +19 -1
  79. data/lib/otto/security/csp/emit_middleware.rb +2 -1
  80. data/lib/otto/security/csp/policy.rb +159 -7
  81. data/lib/otto/security/csp/request_extras.rb +256 -0
  82. data/lib/otto/security/csp/writer.rb +153 -12
  83. data/lib/otto/security/csp.rb +5 -0
  84. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  85. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  86. data/lib/otto/security/rate_limiter.rb +81 -46
  87. data/lib/otto/utils.rb +50 -0
  88. data/lib/otto/version.rb +1 -1
  89. data/lib/otto.rb +9 -11
  90. data/otto.gemspec +0 -2
  91. metadata +32 -41
  92. data/docs/.gitignore +0 -10
  93. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  94. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  95. data/docs/enrichment.md +0 -128
  96. data/docs/geo-country.md +0 -181
  97. data/docs/ipaddr-encoding-quirk.md +0 -34
  98. data/docs/migrating/v2.0.0-pre1.md +0 -276
  99. data/docs/migrating/v2.0.0-pre2.md +0 -338
  100. data/docs/modern-authentication-authorization-landscape.md +0 -558
  101. data/docs/multi-strategy-authentication-design.md +0 -1401
  102. data/docs/reverse-proxy-network-services.md +0 -371
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'request_extras'
6
+
5
7
  class Otto
6
8
  module Security
7
9
  module CSP
@@ -39,6 +41,10 @@ class Otto
39
41
  CSP_HEADER = 'content-security-policy'
40
42
  CONTENT_TYPE_HEADER = 'content-type'
41
43
 
44
+ # Method#parameters types that declare a keyword parameter (used by
45
+ # {.supports_extra_directives?}).
46
+ KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
47
+
42
48
  # Emission modes. `:override` is a deliberate per-request call that
43
49
  # REPLACES any existing CSP (the caller owns this response's policy).
44
50
  # `:backstop` is a passive layer that DEFERS to an existing CSP (it only
@@ -51,23 +57,26 @@ class Otto
51
57
  # written". `policy` is the emitted policy on success, or the pre-existing
52
58
  # policy when a `:backstop` deferred to one. `skip_reason` is one of
53
59
  # `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
54
- # else nil.
60
+ # else nil. `extra_directives` carries the request-scoped extras that
61
+ # were ACTUALLY folded into the policy — entries dropped during the
62
+ # append (absent directive) are excluded — or nil when none landed.
55
63
  class Result
56
64
  # Recognized skip reasons, in the order {Writer.apply} evaluates them.
57
65
  SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
58
66
 
59
- attr_reader :policy, :skip_reason, :mode
67
+ attr_reader :policy, :skip_reason, :mode, :extra_directives
60
68
 
61
- def initialize(applied:, mode:, policy: nil, skip_reason: nil)
69
+ def initialize(applied:, mode:, policy: nil, skip_reason: nil, extra_directives: nil)
62
70
  @applied = applied
63
71
  @mode = mode
64
72
  @policy = policy
65
73
  @skip_reason = skip_reason
74
+ @extra_directives = extra_directives
66
75
  end
67
76
 
68
77
  # Build an "applied" result for a written policy.
69
- def self.applied(policy, mode:)
70
- new(applied: true, mode: mode, policy: policy)
78
+ def self.applied(policy, mode:, extra_directives: nil)
79
+ new(applied: true, mode: mode, policy: policy, extra_directives: extra_directives)
71
80
  end
72
81
 
73
82
  # Build a "skipped" result. `policy` carries the pre-existing policy for
@@ -96,23 +105,35 @@ class Otto
96
105
  # and the policy string ({Otto::Security::Config#generate_nonce_csp}).
97
106
  # @param mode [Symbol] one of {MODES}.
98
107
  # @param development_mode [Boolean] use the development directive set.
108
+ # @param env [Hash, nil] the Rack environment. When given AND the
109
+ # config has opted the channel in
110
+ # ({Otto::Security::Config#enable_csp_request_extras!}), any
111
+ # request-scoped directive extras the app wrote to
112
+ # `env['otto.csp.extra_directives']` are sanitized
113
+ # ({Otto::Security::CSP::RequestExtras.from_env}) and folded
114
+ # ADDITIVELY into the policy. Nil (surfaces with no env in hand)
115
+ # simply builds the policy without extras; without the opt-in the
116
+ # env key is ignored entirely.
99
117
  # @return [Result]
100
118
  # @raise [ArgumentError] if mode is not one of {MODES}
101
119
  # @raise [FrozenError] if a write is attempted against a frozen headers hash
102
- def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
120
+ def self.apply(headers, nonce, config:, mode: :override, development_mode: false, env: nil)
103
121
  unless MODES.include?(mode)
104
122
  raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
105
123
  end
106
124
 
107
- result = evaluate(headers, nonce, config, mode, development_mode)
125
+ result = evaluate(headers, nonce, config, mode, development_mode, env)
108
126
  log_debug(config, result)
109
127
  result
110
128
  end
111
129
 
112
130
  # Guarded core: returns a Result and performs the in-place write when it
113
131
  # applies. Guards are evaluated most-fundamental first so the reported
114
- # skip_reason is stable and meaningful.
115
- def self.evaluate(headers, nonce, config, mode, development_mode)
132
+ # skip_reason is stable and meaningful. Request-scoped extras are
133
+ # resolved only once every guard has passed — so a skipped response
134
+ # never logs extras drops for a policy that was never built — and only
135
+ # when the config opted the channel in (see {.resolve_extras}).
136
+ def self.evaluate(headers, nonce, config, mode, development_mode, env)
116
137
  return Result.skipped(:disabled, mode: mode) unless enabled?(config)
117
138
  return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
118
139
  return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
@@ -120,12 +141,126 @@ class Otto
120
141
  existing = existing_csp(headers)
121
142
  return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
122
143
 
123
- policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
144
+ extras = resolve_extras(config, env)
145
+ policy, applied_extras = build_policy(config, nonce, development_mode, extras, env)
124
146
  write_csp(headers, policy)
125
- Result.applied(policy, mode: mode)
147
+ Result.applied(policy, mode: mode, extra_directives: applied_extras)
126
148
  end
127
149
  private_class_method :evaluate
128
150
 
151
+ # Build the policy string, folding in the request-scoped extras when
152
+ # the config supports them. Returns `[policy, applied_extras]` where
153
+ # applied_extras is the hash of extras entries that ACTUALLY landed in
154
+ # the policy (nil when none did), as reported back by
155
+ # {Policy.append_extra_sources} through the outcome block — never the
156
+ # pre-append input, so {Result#extra_directives} and the debug log can
157
+ # only claim what happened. Entries the append dropped (absent
158
+ # directive) are logged HERE, the one place with the env in hand, with
159
+ # full request context; Policy stays a pure function of its arguments.
160
+ #
161
+ # Configs are duck-typed (see {.enabled?}): one with the pre-#243
162
+ # `generate_nonce_csp` signature would raise ArgumentError on the
163
+ # `extra_directives:` kwarg at request time — violating the extras
164
+ # channel's never-raises invariant — so the kwarg is passed only when
165
+ # the signature declares it. Otherwise the historical call shape is
166
+ # used and the extras are dropped with a single structured warn
167
+ # (`reason: :config_without_extras_support`).
168
+ #
169
+ # Declaring the kwarg is only half the duck-config protocol: opting
170
+ # in means BOTH accepting `extra_directives:` AND invoking the
171
+ # outcome block. A config that takes the kwarg but never yields
172
+ # leaves the outcome unknown — its policy string is still used as
173
+ # returned, but no applied extras are reported
174
+ # ({Result#extra_directives} stays nil; never fabricated from the
175
+ # pre-append input) and a single structured warn
176
+ # (`reason: :config_outcome_not_reported`) flags the gap.
177
+ def self.build_policy(config, nonce, development_mode, extras, env)
178
+ return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil] if extras.nil?
179
+
180
+ unless supports_extra_directives?(config)
181
+ Otto.structured_log(
182
+ :warn, 'CSP request extras dropped',
183
+ Otto::LoggingHelpers.request_context(env).merge(
184
+ directives: extras.keys.join(' '),
185
+ reason: :config_without_extras_support
186
+ )
187
+ )
188
+ return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil]
189
+ end
190
+
191
+ applied = nil
192
+ reported = false
193
+ policy = config.generate_nonce_csp(
194
+ nonce, development_mode: development_mode, extra_directives: extras
195
+ ) do |applied_extras, dropped_extras|
196
+ reported = true
197
+ applied = applied_extras unless applied_extras.empty?
198
+ log_dropped_extras(env, dropped_extras)
199
+ end
200
+ unless reported
201
+ Otto.structured_log(
202
+ :warn, 'CSP request extras outcome unreported',
203
+ Otto::LoggingHelpers.request_context(env).merge(
204
+ directives: extras.keys.join(' '),
205
+ reason: :config_outcome_not_reported
206
+ )
207
+ )
208
+ end
209
+ [policy, applied]
210
+ end
211
+ private_class_method :build_policy
212
+
213
+ # Whether the config's generate_nonce_csp declares the
214
+ # `extra_directives:` keyword (or accepts arbitrary keywords).
215
+ def self.supports_extra_directives?(config)
216
+ parameters = config.method(:generate_nonce_csp).parameters
217
+ parameters.any? { |type, name| KEYWORD_PARAM_TYPES.include?(type) && name == :extra_directives } ||
218
+ parameters.any? { |type, _name| type == :keyrest }
219
+ rescue NameError
220
+ false
221
+ end
222
+ private_class_method :supports_extra_directives?
223
+
224
+ # Log each extras entry the policy build dropped, once per entry, with
225
+ # full request context. The reason distinguishes the two ways an entry
226
+ # fails to land: its directive was absent from the built policy
227
+ # (:absent_directive) or the directive takes no value at all, so a
228
+ # source could never be appended to it (:valueless_directive) — the
229
+ # latter is an app bug worth naming precisely, since the entry is a
230
+ # no-op rather than a policy gap.
231
+ def self.log_dropped_extras(env, dropped)
232
+ return if dropped.nil? || dropped.empty?
233
+
234
+ context = Otto::LoggingHelpers.request_context(env)
235
+ dropped.each do |name, tokens|
236
+ reason = Policy.valueless_directive?(name) ? :valueless_directive : :absent_directive
237
+ Otto.structured_log(
238
+ :warn, 'CSP request extra dropped',
239
+ context.merge(
240
+ directive: name,
241
+ token: Array(tokens).join(' ').inspect.slice(0, 128),
242
+ reason: reason
243
+ )
244
+ )
245
+ end
246
+ end
247
+ private_class_method :log_dropped_extras
248
+
249
+ # Resolve the request-scoped extras, gated on the boot-time opt-in.
250
+ # The env key is a write surface ANY middleware in the Rack stack can
251
+ # reach — a lower-trust position than boot code — so the channel does
252
+ # not exist until {Otto::Security::Config#enable_csp_request_extras!}
253
+ # was called: when disabled (including duck-typed configs without the
254
+ # predicate) the env key is ignored entirely, with no sanitize work
255
+ # and no logs.
256
+ def self.resolve_extras(config, env)
257
+ return nil unless env
258
+ return nil unless config.respond_to?(:csp_request_extras_enabled?) && config.csp_request_extras_enabled?
259
+
260
+ RequestExtras.from_env(env)
261
+ end
262
+ private_class_method :resolve_extras
263
+
129
264
  # In-place, key-scoped write. Delete any case-variant of the CSP key
130
265
  # (correcting a downstream SPEC violation), then write the canonical
131
266
  # lowercase key into the caller's hash. Variant keys are collected before
@@ -182,12 +317,18 @@ class Otto
182
317
 
183
318
  # Uniform debug observability: when the config opts into CSP debugging,
184
319
  # log the outcome — applied policy OR skip reason — so "why didn't my page
185
- # get a CSP?" no longer needs a debugger.
320
+ # get a CSP?" no longer needs a debugger. When request-scoped extras
321
+ # were folded in, note which directives and how many tokens: the
322
+ # EmitMiddleware backstop is otherwise silent about them, so this is
323
+ # the observable trace that a request's extras actually landed.
186
324
  def self.log_debug(config, result)
187
325
  return unless config.respond_to?(:debug_csp?) && config.debug_csp?
188
326
  return unless defined?(Otto.logger) && Otto.logger
189
327
 
190
328
  detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
329
+ if (extras = result.extra_directives)
330
+ detail += " extras[#{extras.keys.join(' ')}](#{extras.each_value.sum(&:length)} tokens)"
331
+ end
191
332
  Otto.logger.debug("[CSP] #{detail}")
192
333
  end
193
334
  private_class_method :log_debug
@@ -14,6 +14,10 @@
14
14
  # Result object, :override / :backstop modes) that every surface
15
15
  # routes through: Otto::Response#apply_csp, the EmitMiddleware,
16
16
  # and the deprecated Otto::Response#send_csp_headers shim.
17
+ # - RequestExtras — reads + sanitizes the opt-in request-scoped directive
18
+ # extras from env['otto.csp.extra_directives'] (delano/otto#243);
19
+ # the Writer folds the survivors in additively via
20
+ # Policy.append_extra_sources.
17
21
  # - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
18
22
  # request consumed a nonce (emit-if-consumed). See
19
23
  # Otto::Security::Core#enable_csp_emission!.
@@ -23,6 +27,7 @@
23
27
  # callback API. See Otto::Security::Core#enable_csp_reporting!.
24
28
 
25
29
  require_relative 'csp/policy'
30
+ require_relative 'csp/request_extras'
26
31
  require_relative 'csp/nonce'
27
32
  require_relative 'csp/writer'
28
33
  require_relative 'csp/report'
@@ -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.8.1'
6
+ VERSION = '2.10.0'
7
7
  end