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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +43 -4
- data/.rubocop.yml +1 -1
- data/Gemfile +12 -3
- data/Gemfile.lock +51 -8
- data/bin/rspec +16 -0
- data/examples/mcp_demo/app.rb +56 -0
- data/examples/mcp_demo/config.ru +68 -0
- data/examples/mcp_demo/routes +9 -0
- data/lib/concurrent_cache_store.rb +68 -0
- data/lib/otto/helpers/validation.rb +83 -0
- data/lib/otto/mcp/auth/token.rb +76 -0
- data/lib/otto/mcp/protocol.rb +167 -0
- data/lib/otto/mcp/rate_limiting.rb +150 -0
- data/lib/otto/mcp/registry.rb +95 -0
- data/lib/otto/mcp/route_parser.rb +82 -0
- data/lib/otto/mcp/server.rb +196 -0
- data/lib/otto/mcp/validation.rb +119 -0
- data/lib/otto/route_definition.rb +15 -15
- data/lib/otto/route_handlers.rb +126 -97
- data/lib/otto/security/config.rb +3 -1
- data/lib/otto/security/rate_limiting.rb +111 -0
- data/lib/otto/security/validator.rb +35 -74
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +127 -1
- data/otto.gemspec +11 -6
- metadata +67 -19
@@ -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
|
39
|
-
@path
|
38
|
+
@verb = verb.to_s.upcase.to_sym
|
39
|
+
@path = path
|
40
40
|
@definition = definition
|
41
|
-
@pattern
|
42
|
-
@keys
|
41
|
+
@pattern = pattern
|
42
|
+
@keys = keys || []
|
43
43
|
|
44
44
|
# Parse the definition into target and options
|
45
|
-
parsed
|
46
|
-
@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
|
52
|
-
@method_name
|
53
|
-
@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 #{
|
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
|
144
|
-
target
|
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
|
-
|
151
|
+
elsif Otto.debug
|
152
152
|
# Malformed parameter, log warning if debug enabled
|
153
|
-
Otto.logger.warn "Ignoring malformed route parameter: #{part}"
|
153
|
+
Otto.logger.warn "Ignoring malformed route parameter: #{part}"
|
154
154
|
end
|
155
155
|
end
|
156
156
|
|
data/lib/otto/route_handlers.rb
CHANGED
@@ -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
|
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,
|
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
|
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']
|
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
|
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
|
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 =>
|
137
|
-
raise NameError, "Unknown class: #{name} (#{
|
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
|
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 =>
|
189
|
-
#
|
190
|
-
|
191
|
-
|
192
|
-
|
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
|
-
|
195
|
-
|
202
|
+
res.status = 500
|
203
|
+
res.headers['content-type'] = 'text/plain'
|
196
204
|
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
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
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
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
|
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
|
-
|
239
|
-
|
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
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
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
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
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
|
-
|
288
|
-
#
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
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
|
-
|
314
|
-
|
315
|
-
|
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.
|
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
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
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 =>
|
383
|
+
request: req,
|
384
|
+
}
|
385
|
+
)
|
386
|
+
rescue StandardError => ex
|
358
387
|
error_id = SecureRandom.hex(8)
|
359
|
-
Otto.logger.error "[#{error_id}] #{
|
360
|
-
Otto.logger.debug "[#{error_id}] Backtrace: #{
|
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
|
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
|
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
|
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
|
data/lib/otto/security/config.rb
CHANGED
@@ -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
|