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
@@ -13,12 +13,24 @@ class Otto
13
13
  include Enumerable
14
14
  include Otto::Core::Freezable
15
15
 
16
+ # Pin tiers honored by #ordered_stack, outward-ascending. #wrap folds the
17
+ # stack with reduce, so a LATER array position is a FURTHER OUT wrapper;
18
+ # sorting by tier therefore sorts by how early the middleware sees the
19
+ # request. Unpinned entries are tier 0 and keep their insertion order.
20
+ #
21
+ # A tier is recorded on the ENTRY (as entry[:pin_tier]), not on the
22
+ # middleware class. Entries are identified by (class, args, options), so
23
+ # the same class can legitimately be registered more than once with
24
+ # different configuration; a class-wide pin would drag those other
25
+ # registrations into the pinned tier along with it.
26
+ PIN_TIERS = {
27
+ outermost: 1,
28
+ entrypoint: 2,
29
+ }.freeze
30
+
16
31
  def initialize
17
32
  @stack = []
18
33
  @middleware_set = Set.new
19
- # Classes pinned to run OUTERMOST regardless of insertion order (see
20
- # the :outermost position in #add_with_position and #ordered_stack).
21
- @outermost = Set.new
22
34
  @on_change_callback = nil
23
35
  end
24
36
 
@@ -52,8 +64,15 @@ class Otto
52
64
 
53
65
  # Add middleware with position hint for optimal ordering
54
66
  #
55
- # Positions:
56
- # - :first innermost (runs last, closest to the app)
67
+ # Positions name a place in the ARRAY; #wrap folds the array with reduce,
68
+ # so array order is the REVERSE of execution order the last entry is the
69
+ # outermost wrapper and therefore the first to see a request.
70
+ #
71
+ # - :first/:innermost — innermost: the LAST middleware to see the request,
72
+ # closest to the app. Note the trap in the older `:first`
73
+ # spelling: it is first-in-array, hence last-to-execute.
74
+ # `:innermost` says the same thing in execution terms and is
75
+ # the preferred spelling.
57
76
  # - :last/nil — append (outermost among currently-registered middleware,
58
77
  # but a later append displaces it)
59
78
  # - :outermost — pin to run OUTERMOST (first to see the request) and STAY
@@ -62,10 +81,18 @@ class Otto
62
81
  # at build time. Use for middleware that must short-circuit
63
82
  # ahead of everything else (e.g. the CSP report receiver,
64
83
  # which must intercept before CSRF).
84
+ # - :entrypoint — pin OUTSIDE even the :outermost tier: the very first
85
+ # middleware to touch a request. Reserved for middleware
86
+ # that must normalize the request before anything else can
87
+ # observe it. Otto pins IPPrivacyMiddleware here so every
88
+ # other middleware — its own, an :outermost pin, and
89
+ # anything the app adds via Otto#use — reads a masked
90
+ # REMOTE_ADDR and the canonical env['otto.client_ip'].
65
91
  #
66
92
  # @param middleware_class [Class] Middleware class
67
93
  # @param args [Array] Middleware arguments
68
- # @param position [Symbol, nil] Position hint (:first, :last, :outermost, or nil)
94
+ # @param position [Symbol, nil] Position hint (:first, :innermost, :last,
95
+ # :outermost, :entrypoint, or nil)
69
96
  def add_with_position(middleware_class, *args, position: nil, **options)
70
97
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
71
98
 
@@ -81,11 +108,10 @@ class Otto
81
108
  entry = { middleware: middleware_class, args: args, options: options }
82
109
 
83
110
  case position
84
- when :first
111
+ when :first, :innermost
85
112
  @stack.unshift(entry)
86
- when :outermost
87
- @stack << entry
88
- @outermost.add(middleware_class)
113
+ when *PIN_TIERS.keys
114
+ @stack << entry.merge(pin_tier: PIN_TIERS.fetch(position))
89
115
  else
90
116
  @stack << entry # :last / nil — default append
91
117
  end
@@ -160,9 +186,9 @@ class Otto
160
186
  # Update middleware set if any matching entries were found
161
187
  return unless matches
162
188
 
163
- # Rebuild the set of unique middleware classes
189
+ # Rebuild the set of unique middleware classes. Pins need no cleanup:
190
+ # each removed entry took its own tier with it.
164
191
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
165
- @outermost.delete(middleware_class)
166
192
  # Notify of change
167
193
  @on_change_callback&.call
168
194
  end
@@ -179,7 +205,6 @@ class Otto
179
205
 
180
206
  @stack.clear
181
207
  @middleware_set.clear
182
- @outermost.clear
183
208
  # Notify of change
184
209
  @on_change_callback&.call
185
210
  end
@@ -192,9 +217,15 @@ class Otto
192
217
  # Build Rack application with middleware chain
193
218
  #
194
219
  # The stack folds via reduce, so the LAST entry becomes the OUTERMOST
195
- # wrapper (first to see the request). #ordered_stack moves any :outermost-
196
- # pinned middleware to the end so it stays outermost regardless of the
197
- # order middleware was registered in.
220
+ # wrapper (first to see the request). #ordered_stack moves any pinned
221
+ # middleware (:outermost, :entrypoint) to the end so it stays outermost
222
+ # regardless of the order middleware was registered in.
223
+ #
224
+ # NOT a request-path method. Its only caller is Otto's build_app!, which
225
+ # runs at construction and again whenever the stack changes; requests are
226
+ # served by the chain it returns. So the ordering work here (and in
227
+ # #ordered_stack) is per-BUILD, not per-request — don't add caching
228
+ # machinery on the assumption that it is hot.
198
229
  def wrap(base_app, security_config = nil)
199
230
  ordered_stack.reduce(base_app) do |app, entry|
200
231
  middleware = entry[:middleware]
@@ -215,11 +246,23 @@ class Otto
215
246
  end
216
247
  end
217
248
 
218
- # Returns list of middleware classes in order
249
+ # Returns list of middleware classes in REGISTRATION order — the order
250
+ # they were added, which is the reverse of execution order and ignores pin
251
+ # tiers. Use #execution_order to see what actually runs first.
219
252
  def middleware_list
220
253
  @stack.map { |entry| entry[:middleware] }
221
254
  end
222
255
 
256
+ # Returns middleware classes in EXECUTION order: the first entry is the
257
+ # outermost wrapper #wrap builds, i.e. the first to see a request. This is
258
+ # #middleware_list resolved through the pin tiers and reversed, so it
259
+ # answers "what does this stack actually do?" without building the app.
260
+ #
261
+ # @return [Array<Class>] outermost (first to execute) first
262
+ def execution_order
263
+ ordered_stack.reverse.map { |entry| entry[:middleware] }
264
+ end
265
+
223
266
  # Detailed introspection
224
267
  def middleware_details
225
268
  @stack.map do |entry|
@@ -254,16 +297,20 @@ class Otto
254
297
 
255
298
  private
256
299
 
257
- # The stack ordered for #wrap: identical to @stack unless some middleware
258
- # is pinned :outermost, in which case pinned entries are moved to the end
259
- # (outermost) while preserving the relative order of both groups. Returns
260
- # @stack itself (no copy) in the common no-pin case, so ordinary apps are
261
- # completely unaffected.
300
+ # The stack ordered for #wrap: identical to @stack unless some entry is
301
+ # pinned, in which case entries are sorted by pin tier (PIN_TIERS,
302
+ # outward-ascending) so pinned entries move to the end (outermost) while
303
+ # the relative order within every tier is preserved. Ruby's sort_by is not
304
+ # stable, hence the explicit index tiebreak. Returns @stack itself (no
305
+ # copy) in the common no-pin case, so ordinary apps are unaffected.
306
+ #
307
+ # Runs per build, not per request — see #wrap.
262
308
  def ordered_stack
263
- return @stack if @outermost.empty?
309
+ return @stack if @stack.none? { |entry| entry[:pin_tier] }
264
310
 
265
- pinned, rest = @stack.partition { |entry| @outermost.include?(entry[:middleware]) }
266
- rest + pinned
311
+ @stack.each_with_index
312
+ .sort_by { |entry, index| [entry[:pin_tier] || 0, index] }
313
+ .map(&:first)
267
314
  end
268
315
 
269
316
  def middleware_needs_config?(middleware_class)
@@ -17,7 +17,13 @@ class Otto
17
17
  # Enhanced parsing: split only on first two whitespace boundaries
18
18
  # This preserves parameters in the definition part
19
19
  parts = entry.split(/\s+/, 3)
20
- next if parts.size < 3 # Skip malformed entries
20
+ if parts.size < 3
21
+ # A missing/blank handler must not make the route vanish silently
22
+ # (issue #191): warn unconditionally, not gated behind Otto.debug.
23
+ Otto.structured_log(:warn, 'Malformed route line skipped',
24
+ { line: entry, expected: 'VERB /path Handler [options]' })
25
+ next
26
+ end
21
27
 
22
28
  verb = parts[0]
23
29
  path = parts[1]
@@ -32,10 +38,32 @@ class Otto
32
38
  next
33
39
  end
34
40
 
35
- route = Otto::Route.new verb, path, definition
36
- route.otto = self
37
- path_clean = path.gsub(%r{/$}, '')
38
- @route_definitions[route.definition] = route
41
+ route = Otto::Route.new verb, path, definition
42
+ route.otto = self
43
+ path_clean = path.gsub(%r{/$}, '')
44
+
45
+ # A definition string is not unique (the same handler can be mounted
46
+ # at several verb/path pairs), so @route_definitions keeps the
47
+ # first-loaded route per definition — deterministic, instead of the
48
+ # last-loaded route silently winning — and @routes_by_definition
49
+ # keeps them all for uri() disambiguation (issue #190).
50
+ if (existing = @route_definitions[route.definition])
51
+ # Mounting one handler at several paths is a fully supported
52
+ # pattern (issue #190) — uri() disambiguates by params, so this
53
+ # is informational, not a problem. Debug-gated like other
54
+ # routing diagnostics rather than warning on every boot for
55
+ # valid configs (e.g. `/users/:id` and `/me` aliases).
56
+ Otto.structured_log(:debug, 'Duplicate route definition',
57
+ {
58
+ definition: route.definition,
59
+ kept: "#{existing.verb} #{existing.path}",
60
+ also: "#{route.verb} #{route.path}",
61
+ hint: 'uri() picks the route whose path params match the given params',
62
+ })
63
+ else
64
+ @route_definitions[route.definition] = route
65
+ end
66
+ (@routes_by_definition[route.definition] ||= []) << route
39
67
  if Otto.debug
40
68
  Otto.structured_log(:debug, 'Route loaded',
41
69
  {
@@ -49,6 +77,11 @@ class Otto
49
77
  @routes[route.verb] << route
50
78
  @routes_literal[route.verb] ||= {}
51
79
  @routes_literal[route.verb][path_clean] = route
80
+ rescue Otto::RouteDefinitionError
81
+ # A malformed security-gating option (auth/role/csrf) fails fast at
82
+ # boot rather than serving the route without its intended protection
83
+ # (issue #191). Deliberately not swallowed like other per-line errors.
84
+ raise
52
85
  rescue StandardError => e
53
86
  Otto.structured_log(:error, 'Route load failed',
54
87
  {
@@ -91,7 +124,21 @@ class Otto
91
124
  literal_routes = routes_literal[http_verb] || {}
92
125
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
93
126
 
94
- if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
127
+ # Dynamic-route and static-file dispatch match against the SAME
128
+ # normalized path the literal table and the LocalhostGuard use, so all
129
+ # dispatch paths share one normalization (issue #187). Without this,
130
+ # dynamic routes matched the raw (unescape-only) path: they were
131
+ # stricter about trailing slashes than literal routes (equivalent URLs
132
+ # matched or missed depending on route kind), and invalid-UTF-8 bytes
133
+ # scrubbed for the guard and literal matching survived into the dynamic
134
+ # matcher and safe_file? — the guard-bypass class normalize_path exists
135
+ # to close. normalize_path collapses root to '' after stripping the
136
+ # trailing slash; the regex matcher and safe_file? need a leading slash
137
+ # to be structural (a catch-all `/*` still matches `/`), so restore '/'
138
+ # for them. Literal lookup keeps '' — it already keys root that way.
139
+ dispatch_path = path_info_clean.empty? ? '/' : path_info_clean
140
+
141
+ if static_route && http_verb == :GET && routes_static[:GET].key?(base_path)
95
142
  Otto.structured_log(:debug, 'Route matched',
96
143
  Otto::LoggingHelpers.request_context(env).merge(
97
144
  type: 'static_cached',
@@ -111,7 +158,7 @@ class Otto
111
158
  @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
112
159
  end
113
160
  route.call(env)
114
- elsif static_route && http_verb == :GET && safe_file?(path_info)
161
+ elsif static_route && http_verb == :GET && safe_file?(dispatch_path)
115
162
  Otto.structured_log(:debug, 'Route matched',
116
163
  Otto::LoggingHelpers.request_context(env).merge(
117
164
  type: 'static_new',
@@ -120,7 +167,7 @@ class Otto
120
167
  routes_static[:GET][base_path] = base_path
121
168
  static_route.call(env)
122
169
  else
123
- match_dynamic_route(env, path_info, http_verb, literal_routes)
170
+ match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
124
171
  end
125
172
  end
126
173
 
@@ -144,14 +191,18 @@ class Otto
144
191
 
145
192
  private
146
193
 
147
- def match_dynamic_route(env, path_info, http_verb, literal_routes)
194
+ # +dispatch_path+ is the normalized path from #handle_request (see the
195
+ # +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
196
+ # root's empty string mapped back to '/' so the anchored route regexes can
197
+ # match. It is deliberately NOT the raw +normalize_path+ value.
198
+ def match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
148
199
  extra_params = {}
149
200
  found_route = nil
150
201
  valid_routes = routes[http_verb] || []
151
202
  valid_routes.push(*routes[:GET]) if http_verb == :HEAD
152
203
 
153
204
  valid_routes.each do |route|
154
- next unless (match = route.pattern.match(path_info))
205
+ next unless (match = route.pattern.match(dispatch_path))
155
206
 
156
207
  values = match.captures.to_a
157
208
  # The first capture returned is the entire matched string b/c
@@ -219,6 +270,10 @@ class Otto
219
270
  route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
220
271
  @mcp_server.register_mcp_route(route_info)
221
272
  Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
273
+ rescue Otto::RouteDefinitionError
274
+ # Same fail-fast contract as the normal route loader: a malformed
275
+ # security-gating option must abort boot, not just log-and-drop.
276
+ raise
222
277
  rescue StandardError => e
223
278
  Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
224
279
  end
@@ -229,6 +284,8 @@ class Otto
229
284
  route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
230
285
  @mcp_server.register_mcp_route(route_info)
231
286
  Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
287
+ rescue Otto::RouteDefinitionError
288
+ raise
232
289
  rescue StandardError => e
233
290
  Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
234
291
  end
@@ -14,8 +14,7 @@ class Otto
14
14
  # Otto.default.path 'YourClass.somemethod' #=> /some/path
15
15
  #
16
16
  def uri(route_definition, params = {})
17
- # raise RuntimeError, "Not working"
18
- route = @route_definitions[route_definition]
17
+ route = select_uri_route(route_definition, params)
19
18
  return if route.nil?
20
19
 
21
20
  local_params = params.clone
@@ -39,6 +38,41 @@ class Otto
39
38
  end
40
39
  uri.to_s
41
40
  end
41
+
42
+ private
43
+
44
+ # Pick which route a reverse lookup means when one definition string is
45
+ # mounted at several verb/path pairs (issue #190). Routes whose path
46
+ # placeholders are all present in +params+ are preferred; among those,
47
+ # the route consuming the most params wins. Ties keep load order, so a
48
+ # single-route definition behaves exactly as before.
49
+ #
50
+ # e.g. with `GET /users/:id Account#show` and `GET /me Account#show`:
51
+ # uri('Account#show', id: 5) #=> /users/5
52
+ # uri('Account#show') #=> /me
53
+ def select_uri_route(route_definition, params)
54
+ candidates = routes_for_definition(route_definition)
55
+ # @route_definitions fallback covers hand-assembled instances whose
56
+ # routes never went through Otto#load.
57
+ return candidates.first || @route_definitions[route_definition] if candidates.size <= 1
58
+
59
+ param_keys = params.keys.map(&:to_s)
60
+ satisfied = candidates.select { |route| (required_keys(route) - param_keys).empty? }
61
+ pool = satisfied.empty? ? candidates : satisfied
62
+ pool.max_by { |route| (route.keys & param_keys).size }
63
+ end
64
+
65
+ def routes_for_definition(route_definition)
66
+ (@routes_by_definition && @routes_by_definition[route_definition]) || []
67
+ end
68
+
69
+ # `splat` is a positional catch-all captured from a `*` in the path,
70
+ # not a named parameter a caller would ever pass to uri(). Requiring
71
+ # it before a wildcard route counts as "satisfied" would exclude that
72
+ # route from selection unconditionally (issue #190 review follow-up).
73
+ def required_keys(route)
74
+ route.keys - ['splat']
75
+ end
42
76
  end
43
77
  end
44
78
  end
data/lib/otto/env_keys.rb CHANGED
@@ -82,6 +82,17 @@ class Otto
82
82
  # without depending on the (masked) REMOTE_ADDR
83
83
  VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
84
84
 
85
+ # Whether the connecting peer was the loopback interface.
86
+ # Type: Boolean
87
+ # Set by: IPPrivacyMiddleware (every request, evaluated on the ORIGINAL
88
+ # socket peer BEFORE REMOTE_ADDR is masked/rewritten). A boolean, never
89
+ # an address, so it carries no identifying data.
90
+ # Used by: Otto::CaddyTLS::LocalhostGuard to authenticate a direct local
91
+ # call now that IPPrivacyMiddleware runs outermost. Deliberately the raw
92
+ # peer, not the resolved client IP: forwarded headers must play no part
93
+ # in a localhost trust decision.
94
+ PEER_LOOPBACK = 'otto.peer_loopback'
95
+
85
96
  # =========================================================================
86
97
  # LOCALIZATION (I18N)
87
98
  # =========================================================================
@@ -134,6 +145,27 @@ class Otto
134
145
  # Note: presence also acts as the idempotency guard for the middleware
135
146
  CLIENT_IP = 'otto.client_ip'
136
147
 
148
+ # Verdict-only CIDR membership check over the resolved, UNMASKED client IP
149
+ # Type: Proc — call with an Enumerable of CIDR strings or IPAddr objects,
150
+ # returns true/false
151
+ # Set by: IPPrivacyMiddleware (every path that resolves an IP, all
152
+ # privacy profiles)
153
+ # Used by: Downstream IP policy code (allowlists, denylists, network
154
+ # zones) that needs full /32-/128 precision without changing the
155
+ # observability posture — CLIENT_IP is masked under the default
156
+ # profile, so it cannot express a single host
157
+ # Note: the unmasked IP never lands in env; only this closure does, and a
158
+ # Proc serializes to nothing useful, so env dumps and loggers cannot
159
+ # leak the address accidentally. Returns false when the request had
160
+ # no resolvable client IP (fail-closed for allowlist callers);
161
+ # raises IPAddr::InvalidAddressError for invalid CIDR entries
162
+ # (configuration error). See Otto::Utils.ip_in_cidrs?.
163
+ # Note: setting CLIENT_IP yourself is out of contract — it trips the
164
+ # middleware's idempotency guard, so the unmasked address is never
165
+ # captured and this capability degrades to a logged fail-closed
166
+ # check that denies every range.
167
+ IP_MATCH = 'otto.ip_match'
168
+
137
169
  # Privacy-safe masked IP address
138
170
  # Type: String (e.g., '192.168.1.0')
139
171
  # Set by: IPPrivacyMiddleware
@@ -153,6 +185,17 @@ class Otto
153
185
  # Used by: Session correlation without storing IPs
154
186
  HASHED_IP = 'otto.privacy.hashed_ip'
155
187
 
188
+ # Stable IP correlation hash: identifies the same visitor across days/months
189
+ # Type: String (hexadecimal), or nil when no correlation secret configured
190
+ # Set by: IPPrivacyMiddleware (computed over the FULL client IP,
191
+ # pre-masking, keyed with the caller-configured stable
192
+ # correlation_secret — NOT the daily rotation_key behind HASHED_IP)
193
+ # Used by: Correlating the same visitor across days/months (e.g. audit
194
+ # trails) without ever storing or exposing the real IP
195
+ # Read via: Otto::Request#ip_correlation_hash
196
+ # Contrast: HASHED_IP rotates daily (session-scoped); this is stable.
197
+ CORRELATION_HASH = 'otto.privacy.correlation_hash'
198
+
156
199
  # Privacy fingerprint object
157
200
  # Type: Otto::Privacy::RedactedFingerprint
158
201
  # Set by: IPPrivacyMiddleware
data/lib/otto/errors.rb CHANGED
@@ -11,6 +11,13 @@
11
11
  # otto.register_error_handler(MyApp::ResourceNotFound, status: 404, log_level: :info)
12
12
  #
13
13
  class Otto
14
+ # Raised at route-load time when a route definition is malformed in a way
15
+ # that must not be silently ignored — e.g. a security-gating option
16
+ # (auth/role/csrf) without a value. Unlike generic per-line load errors,
17
+ # this error propagates out of Otto#load so the app fails at boot instead
18
+ # of serving the route with default (less safe) behavior.
19
+ class RouteDefinitionError < StandardError; end
20
+
14
21
  # Base class for all Otto HTTP errors
15
22
  #
16
23
  # Provides default_status and default_log_level class methods that
@@ -76,12 +76,61 @@ class Otto
76
76
  {
77
77
  method: env['REQUEST_METHOD'],
78
78
  path: env['PATH_INFO'],
79
- ip: env['otto.client_ip'] || env['REMOTE_ADDR'], # Canonical client IP (masked when privacy on)
79
+ ip: privacy_safe_ip(env), # Canonical client IP (masked when privacy on)
80
80
  country: env['otto.privacy.geo_country'],
81
81
  user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
82
82
  }.compact
83
83
  end
84
84
 
85
+ # A client IP that is safe to write to a log.
86
+ #
87
+ # Prefers the canonical env['otto.client_ip'] that IPPrivacyMiddleware
88
+ # resolves once per request: masked when privacy is on, and deliberately
89
+ # the real address when the app has disabled privacy. That key is present
90
+ # for anything running inside Otto, since the middleware is pinned
91
+ # outermost.
92
+ #
93
+ # It is ABSENT for code that runs outside Otto's stack — notably
94
+ # Rack::Attack, which the hosting app mounts ahead of Otto (see
95
+ # Otto::Security::Middleware::RateLimitMiddleware), so its 'rack.attack'
96
+ # subscriber observes the raw peer no matter how Otto orders its own
97
+ # middleware. There we mask the address ourselves rather than log it whole:
98
+ # a public IP is reduced with the default precision (privacy config is not
99
+ # reachable from a global subscriber), and private/localhost addresses pass
100
+ # through unmasked, matching the middleware's own exemption so development
101
+ # logs stay readable.
102
+ #
103
+ # Never raises and never returns a raw public address: an unparseable value
104
+ # (a ported REMOTE_ADDR, a malformed forwarded entry) becomes '[redacted]'.
105
+ #
106
+ # @param env [Hash] Rack environment hash
107
+ # @param fallback_ip [String, nil] address to fall back to when the env
108
+ # carries no canonical client IP — e.g. Rack::Request#ip, which factors in
109
+ # forwarded headers. Defaults to REMOTE_ADDR.
110
+ # @return [String, nil] log-safe address, or nil when there is none
111
+ def self.privacy_safe_ip(env, fallback_ip = nil)
112
+ canonical = env['otto.client_ip']
113
+ return canonical unless canonical.to_s.empty?
114
+
115
+ candidate = fallback_ip.to_s.empty? ? env['REMOTE_ADDR'] : fallback_ip
116
+ redact_ip(candidate)
117
+ end
118
+
119
+ # Reduce a raw address to a log-safe form. See .privacy_safe_ip.
120
+ #
121
+ # @param ip [String, nil] raw address
122
+ # @return [String, nil] masked address, the address itself when
123
+ # private/localhost, '[redacted]' when unparseable, nil when blank
124
+ def self.redact_ip(ip)
125
+ address = ip.to_s.strip
126
+ return nil if address.empty?
127
+ return address if Otto::Privacy::IPPrivacy.private_or_localhost?(address)
128
+
129
+ Otto::Privacy::IPPrivacy.mask_ip(address) || '[redacted]'
130
+ rescue ArgumentError
131
+ '[redacted]'
132
+ end
133
+
85
134
  # Log a timed operation with consistent timing and error handling
86
135
  #
87
136
  # @param level [Symbol] The log level (:debug, :info, :warn, :error)
@@ -113,14 +113,17 @@ class Otto
113
113
  def self.configure_mcp_logging
114
114
  return unless defined?(ActiveSupport::Notifications)
115
115
 
116
+ # Masked address only — see the note on the sibling subscriber in
117
+ # Otto::Security::RateLimiting.configure_rack_attack! (issue #219).
116
118
  ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
117
119
  req = payload[:request]
118
120
  endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
121
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
119
122
 
120
123
  if req.path.start_with?(endpoint)
121
- Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
124
+ Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
122
125
  else
123
- Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
126
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
124
127
  end
125
128
  end
126
129
  end
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative '../route_definition'
6
+
5
7
  class Otto
6
8
  module MCP
7
9
  # Parser for MCP route definitions and resource URIs
@@ -64,10 +66,19 @@ class Otto
64
66
  parts = handler_definition.split(/\s+/)
65
67
  options = {}
66
68
 
67
- # First part is the handler class.method
68
- parts[1..-1]&.each do |part|
69
- key, value = part.split('=', 2)
70
- options[key.to_sym] = value if key && value
69
+ # First part is the handler class.method. Delegate token parsing to
70
+ # Otto::RouteDefinition so a bare/empty auth|role|csrf token here
71
+ # fails fast exactly like it does for normal routes (issue #191
72
+ # MCP/TOOL follow-up), instead of silently registering the route
73
+ # without its intended protection.
74
+ parts[1..]&.each do |part|
75
+ pair = Otto::RouteDefinition.parse_option_token(part, "handler #{handler_definition.inspect}")
76
+ if pair
77
+ options[pair[0]] = pair[1]
78
+ else
79
+ Otto.structured_log(:warn, 'Malformed MCP/tool route option ignored',
80
+ { option: part, handler: handler_definition })
81
+ end
71
82
  end
72
83
 
73
84
  options