otto 1.5.0 → 1.6.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.
@@ -0,0 +1,119 @@
1
+ require 'json'
2
+
3
+ begin
4
+ require 'json_schemer'
5
+ rescue LoadError
6
+ # json_schemer is optional - graceful fallback
7
+ end
8
+
9
+ class Otto
10
+ module MCP
11
+ class ValidationError < StandardError; end
12
+
13
+ class Validator
14
+ def initialize
15
+ @schemas = {}
16
+ @json_schemer_available = defined?(JSONSchemer)
17
+ end
18
+
19
+ def validate_request(data)
20
+ return true unless @json_schemer_available
21
+
22
+ schema = mcp_request_schema
23
+ validation_errors = schema.validate(data).to_a
24
+
25
+ unless validation_errors.empty?
26
+ error_messages = validation_errors.map { |error| error['details'] || error['error'] || error.to_s }.join(', ')
27
+ raise ValidationError, "Invalid MCP request: #{error_messages}"
28
+ end
29
+
30
+ true
31
+ end
32
+
33
+ def validate_tool_arguments(tool_name, arguments, schema)
34
+ return true unless @json_schemer_available && schema
35
+
36
+ schemer = JSONSchemer.schema(schema)
37
+ validation_errors = schemer.validate(arguments).to_a
38
+
39
+ unless validation_errors.empty?
40
+ error_messages = validation_errors.map { |error| error['details'] || error['error'] || error.to_s }.join(', ')
41
+ raise ValidationError, "Invalid arguments for tool #{tool_name}: #{error_messages}"
42
+ end
43
+
44
+ true
45
+ end
46
+
47
+ private
48
+
49
+ def mcp_request_schema
50
+ @schemas[:mcp_request] ||= JSONSchemer.schema({
51
+ type: 'object',
52
+ required: %w[jsonrpc method id],
53
+ properties: {
54
+ jsonrpc: { const: '2.0' },
55
+ method: { type: 'string' },
56
+ id: {},
57
+ params: { type: 'object' },
58
+ },
59
+ additionalProperties: false,
60
+ },
61
+ )
62
+ end
63
+ end
64
+
65
+ class ValidationMiddleware
66
+ def initialize(app, _security_config = nil)
67
+ @app = app
68
+ @validator = Validator.new
69
+ end
70
+
71
+ def call(env)
72
+ # Only validate MCP endpoints
73
+ return @app.call(env) unless mcp_endpoint?(env)
74
+
75
+ request = Rack::Request.new(env)
76
+
77
+ if request.post? && request.content_type&.include?('application/json')
78
+ begin
79
+ body = request.body.read
80
+ data = JSON.parse(body)
81
+ @validator.validate_request(data)
82
+
83
+ # Reset body for downstream middleware
84
+ request.body.rewind if request.body.respond_to?(:rewind)
85
+ rescue JSON::ParserError => ex
86
+ return validation_error_response(nil, "Invalid JSON: #{ex.message}")
87
+ rescue ValidationError => ex
88
+ return validation_error_response(data&.dig('id'), ex.message)
89
+ end
90
+ end
91
+
92
+ @app.call(env)
93
+ end
94
+
95
+ private
96
+
97
+ def mcp_endpoint?(env)
98
+ endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
99
+ path = env['PATH_INFO'].to_s
100
+ path.start_with?(endpoint)
101
+ end
102
+
103
+ def validation_error_response(id, message)
104
+ body = JSON.generate({
105
+ jsonrpc: '2.0',
106
+ id: id,
107
+ error: {
108
+ code: -32_600,
109
+ message: 'Invalid Request',
110
+ data: message,
111
+ },
112
+ },
113
+ )
114
+
115
+ [400, { 'content-type' => 'application/json' }, [body]]
116
+ end
117
+ end
118
+ end
119
+ end
@@ -35,22 +35,22 @@ class Otto
35
35
  attr_reader :keys
36
36
 
37
37
  def initialize(verb, path, definition, pattern: nil, keys: nil)
38
- @verb = verb.to_s.upcase.to_sym
39
- @path = path
38
+ @verb = verb.to_s.upcase.to_sym
39
+ @path = path
40
40
  @definition = definition
41
- @pattern = pattern
42
- @keys = keys || []
41
+ @pattern = pattern
42
+ @keys = keys || []
43
43
 
44
44
  # Parse the definition into target and options
45
- parsed = parse_definition(definition)
46
- @target = parsed[:target]
45
+ parsed = parse_definition(definition)
46
+ @target = parsed[:target]
47
47
  @options = parsed[:options].freeze
48
48
 
49
49
  # Parse the target into class, method, and kind
50
50
  target_parsed = parse_target(@target)
51
- @klass_name = target_parsed[:klass_name]
52
- @method_name = target_parsed[:method_name]
53
- @kind = target_parsed[:kind]
51
+ @klass_name = target_parsed[:klass_name]
52
+ @method_name = target_parsed[:method_name]
53
+ @kind = target_parsed[:kind]
54
54
 
55
55
  # Freeze for immutability
56
56
  freeze
@@ -118,7 +118,7 @@ class Otto
118
118
  kind: @kind,
119
119
  options: @options,
120
120
  pattern: @pattern,
121
- keys: @keys
121
+ keys: @keys,
122
122
  }
123
123
  end
124
124
 
@@ -131,7 +131,7 @@ class Otto
131
131
  # Detailed inspection
132
132
  # @return [String]
133
133
  def inspect
134
- "#<Otto::RouteDefinition #{to_s} options=#{@options.inspect}>"
134
+ "#<Otto::RouteDefinition #{self} options=#{@options.inspect}>"
135
135
  end
136
136
 
137
137
  private
@@ -140,17 +140,17 @@ class Otto
140
140
  # @param definition [String] The route definition
141
141
  # @return [Hash] Hash with :target and :options keys
142
142
  def parse_definition(definition)
143
- parts = definition.split(/\s+/)
144
- target = parts.shift
143
+ parts = definition.split(/\s+/)
144
+ target = parts.shift
145
145
  options = {}
146
146
 
147
147
  parts.each do |part|
148
148
  key, value = part.split('=', 2)
149
149
  if key && value
150
150
  options[key.to_sym] = value
151
- else
151
+ elsif Otto.debug
152
152
  # Malformed parameter, log warning if debug enabled
153
- Otto.logger.warn "Ignoring malformed route parameter: #{part}" if Otto.debug
153
+ Otto.logger.warn "Ignoring malformed route parameter: #{part}"
154
154
  end
155
155
  end
156
156
 
@@ -4,7 +4,6 @@ class Otto
4
4
  # Pluggable Route Handler Factory (Phase 4)
5
5
  # Enables different execution patterns while maintaining backward compatibility
6
6
  module RouteHandlers
7
-
8
7
  # Factory for creating appropriate handlers based on route definitions
9
8
  class HandlerFactory
10
9
  # Create a handler for the given route definition
@@ -32,7 +31,7 @@ class Otto
32
31
 
33
32
  def initialize(route_definition, otto_instance = nil)
34
33
  @route_definition = route_definition
35
- @otto_instance = otto_instance
34
+ @otto_instance = otto_instance
36
35
  end
37
36
 
38
37
  # Execute the route handler
@@ -40,7 +39,7 @@ class Otto
40
39
  # @param extra_params [Hash] Additional parameters
41
40
  # @return [Array] Rack response array
42
41
  def call(env, extra_params = {})
43
- raise NotImplementedError, "Subclasses must implement #call"
42
+ raise NotImplementedError, 'Subclasses must implement #call'
44
43
  end
45
44
 
46
45
  protected
@@ -63,20 +62,20 @@ class Otto
63
62
  res.request = req
64
63
 
65
64
  # Make security config available to response helpers
66
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
65
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
67
66
  env['otto.security_config'] = otto_instance.security_config
68
67
  end
69
68
 
70
69
  # Make route definition and options available to middleware and handlers
71
70
  env['otto.route_definition'] = route_definition
72
- env['otto.route_options'] = route_definition.options
71
+ env['otto.route_options'] = route_definition.options
73
72
 
74
73
  # Process parameters through security layer
75
74
  req.params.merge! extra_params
76
75
  req.params.replace Otto::Static.indifferent_params(req.params)
77
76
 
78
77
  # Add security headers
79
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
78
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
80
79
  otto_instance.security_config.security_headers.each do |header, value|
81
80
  res.headers[header] = value
82
81
  end
@@ -89,7 +88,7 @@ class Otto
89
88
  end
90
89
 
91
90
  # Add security helpers if CSRF is enabled
92
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config&.csrf_enabled?
91
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config&.csrf_enabled?
93
92
  res.extend Otto::Security::CSRFHelpers
94
93
  end
95
94
 
@@ -133,8 +132,8 @@ class Otto
133
132
  name.split('::').inject(Object) do |scope, const_name|
134
133
  scope.const_get(const_name)
135
134
  end
136
- rescue NameError => e
137
- raise NameError, "Unknown class: #{name} (#{e})"
135
+ rescue NameError => ex
136
+ raise NameError, "Unknown class: #{name} (#{ex})"
138
137
  end
139
138
  end
140
139
 
@@ -152,7 +151,7 @@ class Otto
152
151
  # Initialize Logic class with standard parameters
153
152
  # Logic classes expect: session, user, params, locale
154
153
  logic_params = req.params.merge(extra_params)
155
- locale = env['otto.locale'] || 'en'
154
+ locale = env['otto.locale'] || 'en'
156
155
 
157
156
  logic = if target_class.instance_method(:initialize).arity == 4
158
157
  # Standard Logic class constructor
@@ -160,7 +159,7 @@ class Otto
160
159
  auth_result&.session,
161
160
  auth_result&.user,
162
161
  logic_params,
163
- locale
162
+ locale,
164
163
  )
165
164
  else
166
165
  # Fallback for custom constructors
@@ -182,28 +181,38 @@ class Otto
182
181
  handle_response(result, res, {
183
182
  logic_instance: logic,
184
183
  request: req,
185
- status_code: logic.respond_to?(:status_code) ? logic.status_code : nil
186
- })
187
-
188
- rescue => e
189
- # Error handling - return 500 with proper headers like main Otto error handler
190
- error_id = SecureRandom.hex(8)
191
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
192
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
184
+ status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
185
+ }
186
+ )
187
+ rescue StandardError => ex
188
+ # Check if we're being called through Otto's integrated context (vs direct handler testing)
189
+ # In integrated context, let Otto's centralized error handler manage the response
190
+ # In direct testing context, handle errors locally for unit testing
191
+ if otto_instance
192
+ # Log error for handler-specific context but let Otto's centralized error handler manage the response
193
+ Otto.logger.error "[LogicClassHandler] #{ex.class}: #{ex.message}"
194
+ Otto.logger.debug "[LogicClassHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
195
+ raise ex # Re-raise to let Otto's centralized error handler manage the response
196
+ else
197
+ # Direct handler testing context - handle errors locally with security improvements
198
+ error_id = SecureRandom.hex(8)
199
+ Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
200
+ Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
193
201
 
194
- res.status = 500
195
- res.headers['content-type'] = 'text/plain'
202
+ res.status = 500
203
+ res.headers['content-type'] = 'text/plain'
196
204
 
197
- if Otto.env?(:dev, :development)
198
- res.write "Server error (ID: #{error_id}). Check logs for details."
199
- else
200
- res.write "An error occurred. Please try again later."
201
- end
205
+ if Otto.env?(:dev, :development)
206
+ res.write "Server error (ID: #{error_id}). Check logs for details."
207
+ else
208
+ res.write 'An error occurred. Please try again later.'
209
+ end
202
210
 
203
- # Add security headers if available
204
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
205
- otto_instance.security_config.security_headers.each do |header, value|
206
- res.headers[header] = value
211
+ # Add security headers if available
212
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
213
+ otto_instance.security_config.security_headers.each do |header, value|
214
+ res.headers[header] = value
215
+ end
207
216
  end
208
217
  end
209
218
  end
@@ -225,35 +234,45 @@ class Otto
225
234
 
226
235
  # Create instance and call method (existing Otto behavior)
227
236
  instance = target_class.new(req, res)
228
- result = instance.send(route_definition.method_name)
237
+ result = instance.send(route_definition.method_name)
229
238
 
230
239
  # Only handle response if response_type is not default
231
240
  if route_definition.response_type != 'default'
232
241
  handle_response(result, res, {
233
242
  instance: instance,
234
- request: req
235
- })
243
+ request: req,
244
+ }
245
+ )
236
246
  end
247
+ rescue StandardError => ex
248
+ # Check if we're being called through Otto's integrated context (vs direct handler testing)
249
+ # In integrated context, let Otto's centralized error handler manage the response
250
+ # In direct testing context, handle errors locally for unit testing
251
+ if otto_instance
252
+ # Log error for handler-specific context but let Otto's centralized error handler manage the response
253
+ Otto.logger.error "[InstanceMethodHandler] #{ex.class}: #{ex.message}"
254
+ Otto.logger.debug "[InstanceMethodHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
255
+ raise ex # Re-raise to let Otto's centralized error handler manage the response
256
+ else
257
+ # Direct handler testing context - handle errors locally with security improvements
258
+ error_id = SecureRandom.hex(8)
259
+ Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
260
+ Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
237
261
 
238
- rescue => e
239
- # Error handling - return 500 with proper headers like main Otto error handler
240
- error_id = SecureRandom.hex(8)
241
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
242
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
243
-
244
- res.status = 500
245
- res.headers['content-type'] = 'text/plain'
262
+ res.status = 500
263
+ res.headers['content-type'] = 'text/plain'
246
264
 
247
- if Otto.env?(:dev, :development)
248
- res.write "Server error (ID: #{error_id}). Check logs for details."
249
- else
250
- res.write "An error occurred. Please try again later."
251
- end
265
+ if Otto.env?(:dev, :development)
266
+ res.write "Server error (ID: #{error_id}). Check logs for details."
267
+ else
268
+ res.write 'An error occurred. Please try again later.'
269
+ end
252
270
 
253
- # Add security headers if available
254
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
255
- otto_instance.security_config.security_headers.each do |header, value|
256
- res.headers[header] = value
271
+ # Add security headers if available
272
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
273
+ otto_instance.security_config.security_headers.each do |header, value|
274
+ res.headers[header] = value
275
+ end
257
276
  end
258
277
  end
259
278
  end
@@ -280,48 +299,58 @@ class Otto
280
299
  if route_definition.response_type != 'default'
281
300
  handle_response(result, res, {
282
301
  class: target_class,
283
- request: req
284
- })
302
+ request: req,
303
+ }
304
+ )
285
305
  end
286
-
287
- rescue => e
288
- # Error handling - return 500 with proper headers like main Otto error handler
289
- error_id = SecureRandom.hex(8)
290
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
291
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
292
-
293
- res.status = 500
294
-
295
- # Content negotiation for error response
296
- accept_header = env['HTTP_ACCEPT'].to_s
297
- if accept_header.include?('application/json')
298
- res.headers['content-type'] = 'application/json'
299
- error_data = if Otto.env?(:dev, :development)
300
- {
301
- error: 'Internal Server Error',
302
- message: 'Server error occurred. Check logs for details.',
303
- error_id: error_id,
304
- }
305
- else
306
- {
307
- error: 'Internal Server Error',
308
- message: 'An error occurred. Please try again later.',
309
- }
310
- end
311
- res.write JSON.generate(error_data)
306
+ rescue StandardError => ex
307
+ # Check if we're being called through Otto's integrated context (vs direct handler testing)
308
+ # In integrated context, let Otto's centralized error handler manage the response
309
+ # In direct testing context, handle errors locally for unit testing
310
+ if otto_instance
311
+ # Log error for handler-specific context but let Otto's centralized error handler manage the response
312
+ Otto.logger.error "[ClassMethodHandler] #{ex.class}: #{ex.message}"
313
+ Otto.logger.debug "[ClassMethodHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
314
+ raise ex # Re-raise to let Otto's centralized error handler manage the response
312
315
  else
313
- res.headers['content-type'] = 'text/plain'
314
- if Otto.env?(:dev, :development)
315
- res.write "Server error (ID: #{error_id}). Check logs for details."
316
+ # Direct handler testing context - handle errors locally with security improvements
317
+ error_id = SecureRandom.hex(8)
318
+ Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
319
+ Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
320
+
321
+ res.status = 500
322
+
323
+ # Content negotiation for error response
324
+ accept_header = env['HTTP_ACCEPT'].to_s
325
+ if accept_header.include?('application/json')
326
+ res.headers['content-type'] = 'application/json'
327
+ error_data = if Otto.env?(:dev, :development)
328
+ {
329
+ error: 'Internal Server Error',
330
+ message: 'Server error occurred. Check logs for details.',
331
+ error_id: error_id,
332
+ }
333
+ else
334
+ {
335
+ error: 'Internal Server Error',
336
+ message: 'An error occurred. Please try again later.',
337
+ }
338
+ end
339
+ res.write JSON.generate(error_data)
316
340
  else
317
- res.write "An error occurred. Please try again later."
341
+ res.headers['content-type'] = 'text/plain'
342
+ if Otto.env?(:dev, :development)
343
+ res.write "Server error (ID: #{error_id}). Check logs for details."
344
+ else
345
+ res.write 'An error occurred. Please try again later.'
346
+ end
318
347
  end
319
- end
320
348
 
321
- # Add security headers if available
322
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
323
- otto_instance.security_config.security_headers.each do |header, value|
324
- res.headers[header] = value
349
+ # Add security headers if available
350
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
351
+ otto_instance.security_config.security_headers.each do |header, value|
352
+ res.headers[header] = value
353
+ end
325
354
  end
326
355
  end
327
356
  end
@@ -339,7 +368,7 @@ class Otto
339
368
  begin
340
369
  # Security: Lambda handlers require pre-configured procs from Otto instance
341
370
  # This prevents code injection via eval and maintains security
342
- handler_name = route_definition.klass_name
371
+ handler_name = route_definition.klass_name
343
372
  lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
344
373
 
345
374
  lambda_proc = lambda_registry[handler_name]
@@ -351,25 +380,25 @@ class Otto
351
380
 
352
381
  handle_response(result, res, {
353
382
  lambda: lambda_proc,
354
- request: req
355
- })
356
-
357
- rescue => e
383
+ request: req,
384
+ }
385
+ )
386
+ rescue StandardError => ex
358
387
  error_id = SecureRandom.hex(8)
359
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
360
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
388
+ Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
389
+ Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
361
390
 
362
- res.status = 500
391
+ res.status = 500
363
392
  res.headers['content-type'] = 'text/plain'
364
393
 
365
394
  if Otto.env?(:dev, :development)
366
395
  res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
367
396
  else
368
- res.write "An error occurred. Please try again later."
397
+ res.write 'An error occurred. Please try again later.'
369
398
  end
370
399
 
371
400
  # Add security headers if available
372
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
401
+ if otto_instance.respond_to?(:security_config) && otto_instance.security_config
373
402
  otto_instance.security_config.security_headers.each do |header, value|
374
403
  res.headers[header] = value
375
404
  end
@@ -25,7 +25,8 @@ class Otto
25
25
  :max_request_size, :max_param_depth, :max_param_keys,
26
26
  :trusted_proxies, :require_secure_cookies,
27
27
  :security_headers, :input_validation,
28
- :csp_nonce_enabled, :debug_csp
28
+ :csp_nonce_enabled, :debug_csp, :mcp_auth,
29
+ :rate_limiting_config
29
30
 
30
31
  # Initialize security configuration with safe defaults
31
32
  #
@@ -45,6 +46,7 @@ class Otto
45
46
  @input_validation = true
46
47
  @csp_nonce_enabled = false
47
48
  @debug_csp = false
49
+ @rate_limiting_config = {}
48
50
  end
49
51
 
50
52
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+
3
+ begin
4
+ require 'rack/attack'
5
+ rescue LoadError
6
+ # rack-attack is optional - graceful fallback
7
+ end
8
+
9
+ class Otto
10
+ module Security
11
+ class RateLimiting
12
+ def self.configure_rack_attack!(config = {})
13
+ return unless defined?(Rack::Attack)
14
+
15
+ # Use provided cache store or default
16
+ if config[:cache_store]
17
+ Rack::Attack.cache.store = config[:cache_store]
18
+ end
19
+
20
+ # Default rules
21
+ default_requests_per_minute = config.fetch(:requests_per_minute, 100)
22
+
23
+ # General request throttling
24
+ Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
25
+ request.ip unless request.path.start_with?('/_') # Skip internal paths by default
26
+ end
27
+
28
+ # Apply custom rules if provided
29
+ if config[:custom_rules]
30
+ config[:custom_rules].each do |name, rule_config|
31
+ limit = rule_config[:limit]
32
+ period = rule_config[:period] || 60
33
+ condition = rule_config[:condition]
34
+
35
+ Rack::Attack.throttle(name.to_s, limit: limit, period: period) do |request|
36
+ if condition
37
+ request.ip if condition.call(request)
38
+ else
39
+ request.ip
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Custom response for rate limited requests
46
+ Rack::Attack.throttled_responder = lambda do |request|
47
+ match_data = request.env['rack.attack.match_data']
48
+ now = match_data[:epoch_time]
49
+
50
+ headers = {
51
+ 'content-type' => 'application/json',
52
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
53
+ }
54
+
55
+ # Check if request expects JSON
56
+ accept_header = request.env['HTTP_ACCEPT'].to_s
57
+ if accept_header.include?('application/json')
58
+ error_response = {
59
+ error: 'Rate limit exceeded',
60
+ message: 'Too many requests',
61
+ retry_after: headers['retry-after'].to_i,
62
+ limit: match_data[:limit],
63
+ period: match_data[:period],
64
+ }
65
+ [429, headers, [JSON.generate(error_response)]]
66
+ else
67
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
68
+ headers['content-type'] = 'text/plain'
69
+ [429, headers, [body]]
70
+ end
71
+ end
72
+
73
+ # Log blocked requests if ActiveSupport is available
74
+ return unless defined?(ActiveSupport::Notifications)
75
+
76
+ ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
77
+ req = payload[:request]
78
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
79
+ end
80
+ end
81
+ end
82
+
83
+ class RateLimitMiddleware
84
+ def initialize(app, security_config = nil)
85
+ @app = app
86
+ @security_config = security_config
87
+ @rate_limiter_available = defined?(Rack::Attack)
88
+
89
+ if @rate_limiter_available
90
+ configure_rate_limiting
91
+ else
92
+ Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
93
+ end
94
+ end
95
+
96
+ def call(env)
97
+ return @app.call(env) unless @rate_limiter_available
98
+
99
+ # Let rack-attack handle the rate limiting
100
+ @app.call(env)
101
+ end
102
+
103
+ private
104
+
105
+ def configure_rate_limiting
106
+ config = @security_config&.rate_limiting_config || {}
107
+ RateLimiting.configure_rack_attack!(config)
108
+ end
109
+ end
110
+ end
111
+ end