otto 1.6.0 → 2.0.0.pre1
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 +1 -1
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +10 -3
- data/Gemfile.lock +23 -28
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/server.rb +21 -11
- data/lib/otto/mcp/validation.rb +14 -10
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +28 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +9 -9
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +29 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -12
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +142 -498
- data/otto.gemspec +2 -2
- metadata +89 -28
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/router.rb
|
4
|
+
|
5
|
+
require_relative '../mcp/route_parser'
|
6
|
+
|
7
|
+
class Otto
|
8
|
+
module Core
|
9
|
+
# Router module providing route loading and request dispatching functionality
|
10
|
+
module Router
|
11
|
+
def load(path)
|
12
|
+
path = File.expand_path(path)
|
13
|
+
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
14
|
+
|
15
|
+
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
|
16
|
+
raw.each do |entry|
|
17
|
+
# Enhanced parsing: split only on first two whitespace boundaries
|
18
|
+
# This preserves parameters in the definition part
|
19
|
+
parts = entry.split(/\s+/, 3)
|
20
|
+
next if parts.size < 3 # Skip malformed entries
|
21
|
+
|
22
|
+
verb = parts[0]
|
23
|
+
path = parts[1]
|
24
|
+
definition = parts[2]
|
25
|
+
|
26
|
+
# Check for MCP routes
|
27
|
+
if Otto::MCP::RouteParser.is_mcp_route?(definition)
|
28
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
29
|
+
|
30
|
+
handle_mcp_route(verb, path, definition)
|
31
|
+
next
|
32
|
+
elsif Otto::MCP::RouteParser.is_tool_route?(definition)
|
33
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
34
|
+
|
35
|
+
handle_tool_route(verb, path, definition)
|
36
|
+
next
|
37
|
+
end
|
38
|
+
|
39
|
+
route = Otto::Route.new verb, path, definition
|
40
|
+
route.otto = self
|
41
|
+
path_clean = path.gsub(%r{/$}, '')
|
42
|
+
@route_definitions[route.definition] = route
|
43
|
+
Otto.logger.debug "route: #{route.pattern}" if Otto.debug
|
44
|
+
@routes[route.verb] ||= []
|
45
|
+
@routes[route.verb] << route
|
46
|
+
@routes_literal[route.verb] ||= {}
|
47
|
+
@routes_literal[route.verb][path_clean] = route
|
48
|
+
rescue StandardError => e
|
49
|
+
Otto.logger.error "Bad route in #{path}: #{entry} (Error: #{e.message})"
|
50
|
+
end
|
51
|
+
self
|
52
|
+
end
|
53
|
+
|
54
|
+
def handle_request(env)
|
55
|
+
locale = determine_locale env
|
56
|
+
env['rack.locale'] = locale
|
57
|
+
env['otto.locale_config'] = @locale_config if @locale_config
|
58
|
+
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
59
|
+
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
60
|
+
path_info = '/' if path_info.to_s.empty?
|
61
|
+
|
62
|
+
begin
|
63
|
+
path_info_clean = path_info
|
64
|
+
.encode(
|
65
|
+
'UTF-8', # Target encoding
|
66
|
+
invalid: :replace, # Replace invalid byte sequences
|
67
|
+
undef: :replace, # Replace characters undefined in UTF-8
|
68
|
+
replace: '' # Use empty string for replacement
|
69
|
+
)
|
70
|
+
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
71
|
+
rescue ArgumentError => e
|
72
|
+
# Log the error but don't expose details
|
73
|
+
Otto.logger.error '[Otto.handle_request] Path encoding error'
|
74
|
+
Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
|
75
|
+
# Set a default value or use the original path_info
|
76
|
+
path_info_clean = path_info
|
77
|
+
end
|
78
|
+
|
79
|
+
base_path = File.split(path_info).first
|
80
|
+
# Files in the root directory can refer to themselves
|
81
|
+
base_path = path_info if base_path == '/'
|
82
|
+
http_verb = env['REQUEST_METHOD'].upcase.to_sym
|
83
|
+
literal_routes = routes_literal[http_verb] || {}
|
84
|
+
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
85
|
+
|
86
|
+
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
87
|
+
# Otto.logger.debug " request: #{path_info} (static)"
|
88
|
+
static_route.call(env)
|
89
|
+
elsif literal_routes.has_key?(path_info_clean)
|
90
|
+
route = literal_routes[path_info_clean]
|
91
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
|
92
|
+
route.call(env)
|
93
|
+
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
94
|
+
Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
|
95
|
+
routes_static[:GET][base_path] = base_path
|
96
|
+
static_route.call(env)
|
97
|
+
else
|
98
|
+
match_dynamic_route(env, path_info, http_verb, literal_routes)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def determine_locale(env)
|
103
|
+
accept_langs = env['HTTP_ACCEPT_LANGUAGE']
|
104
|
+
accept_langs = option[:locale] if accept_langs.to_s.empty?
|
105
|
+
locales = []
|
106
|
+
unless accept_langs.empty?
|
107
|
+
locales = accept_langs.split(',').map do |l|
|
108
|
+
l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
|
109
|
+
l.split(';q=')
|
110
|
+
end.sort_by do |_locale, qvalue|
|
111
|
+
qvalue.to_f
|
112
|
+
end.collect do |locale, _qvalue|
|
113
|
+
locale
|
114
|
+
end.reverse
|
115
|
+
end
|
116
|
+
Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
|
117
|
+
locales.empty? ? nil : locales
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def match_dynamic_route(env, path_info, http_verb, literal_routes)
|
123
|
+
extra_params = {}
|
124
|
+
found_route = nil
|
125
|
+
valid_routes = routes[http_verb] || []
|
126
|
+
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
127
|
+
|
128
|
+
valid_routes.each do |route|
|
129
|
+
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
130
|
+
next unless (match = route.pattern.match(path_info))
|
131
|
+
|
132
|
+
values = match.captures.to_a
|
133
|
+
# The first capture returned is the entire matched string b/c
|
134
|
+
# we wrapped the entire regex in parens. We don't need it to
|
135
|
+
# the full match.
|
136
|
+
values.shift
|
137
|
+
extra_params = build_route_params(route, values)
|
138
|
+
found_route = route
|
139
|
+
break
|
140
|
+
end
|
141
|
+
|
142
|
+
found_route ||= literal_routes['/404']
|
143
|
+
if found_route
|
144
|
+
found_route.call env, extra_params
|
145
|
+
else
|
146
|
+
@not_found || Otto::Static.not_found
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def build_route_params(route, values)
|
151
|
+
if route.keys.any?
|
152
|
+
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
153
|
+
if k == 'splat'
|
154
|
+
(hash[k] ||= []) << v
|
155
|
+
else
|
156
|
+
hash[k] = v
|
157
|
+
end
|
158
|
+
end
|
159
|
+
elsif values.any?
|
160
|
+
{ 'captures' => values }
|
161
|
+
else
|
162
|
+
{}
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def handle_mcp_route(verb, path, definition)
|
167
|
+
route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
|
168
|
+
@mcp_server.register_mcp_route(route_info)
|
169
|
+
Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
|
170
|
+
rescue StandardError => e
|
171
|
+
Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
|
172
|
+
end
|
173
|
+
|
174
|
+
def handle_tool_route(verb, path, definition)
|
175
|
+
route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
|
176
|
+
@mcp_server.register_mcp_route(route_info)
|
177
|
+
Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
|
178
|
+
rescue StandardError => e
|
179
|
+
Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/uri_generator.rb
|
4
|
+
|
5
|
+
require 'uri'
|
6
|
+
|
7
|
+
class Otto
|
8
|
+
module Core
|
9
|
+
# URI generation module providing path and URL generation for route definitions
|
10
|
+
module UriGenerator
|
11
|
+
# Return the URI path for the given +route_definition+
|
12
|
+
# e.g.
|
13
|
+
#
|
14
|
+
# Otto.default.path 'YourClass.somemethod' #=> /some/path
|
15
|
+
#
|
16
|
+
def uri(route_definition, params = {})
|
17
|
+
# raise RuntimeError, "Not working"
|
18
|
+
route = @route_definitions[route_definition]
|
19
|
+
return if route.nil?
|
20
|
+
|
21
|
+
local_params = params.clone
|
22
|
+
local_path = route.path.clone
|
23
|
+
|
24
|
+
keys_to_remove = []
|
25
|
+
local_params.each_pair do |k, v|
|
26
|
+
next unless local_path.match(":#{k}")
|
27
|
+
|
28
|
+
local_path.gsub!(":#{k}", v.to_s)
|
29
|
+
keys_to_remove << k
|
30
|
+
end
|
31
|
+
keys_to_remove.each { |k| local_params.delete(k) }
|
32
|
+
|
33
|
+
uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
|
34
|
+
unless local_params.empty?
|
35
|
+
query_string = local_params.map do |k, v|
|
36
|
+
"#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}"
|
37
|
+
end.join('&')
|
38
|
+
uri.query = query_string
|
39
|
+
end
|
40
|
+
uri.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/otto/design_system.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/design_system.rb
|
2
4
|
|
3
5
|
class Otto
|
@@ -112,11 +114,11 @@ class Otto
|
|
112
114
|
return '' if text.nil?
|
113
115
|
|
114
116
|
text.to_s
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
117
|
+
.gsub('&', '&')
|
118
|
+
.gsub('<', '<')
|
119
|
+
.gsub('>', '>')
|
120
|
+
.gsub('"', '"')
|
121
|
+
.gsub("'", ''')
|
120
122
|
end
|
121
123
|
|
122
124
|
def otto_styles
|
data/lib/otto/helpers/base.rb
CHANGED
data/lib/otto/helpers/request.rb
CHANGED
@@ -1,8 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/helpers/request.rb
|
2
4
|
|
3
5
|
require_relative 'base'
|
4
6
|
|
5
7
|
class Otto
|
8
|
+
# Request helper methods providing HTTP request handling utilities
|
6
9
|
module RequestHelpers
|
7
10
|
include Otto::BaseHelpers
|
8
11
|
|
@@ -14,9 +17,7 @@ class Otto
|
|
14
17
|
remote_addr = env['REMOTE_ADDR']
|
15
18
|
|
16
19
|
# If we don't have a security config or trusted proxies, use direct connection
|
17
|
-
if !otto_security_config || !trusted_proxy?(remote_addr)
|
18
|
-
return validate_ip_address(remote_addr)
|
19
|
-
end
|
20
|
+
return validate_ip_address(remote_addr) if !otto_security_config || !trusted_proxy?(remote_addr)
|
20
21
|
|
21
22
|
# Check forwarded headers from trusted proxies
|
22
23
|
forwarded_ips = [
|
@@ -152,12 +153,12 @@ class Otto
|
|
152
153
|
|
153
154
|
# RFC 1918 private ranges and loopback
|
154
155
|
private_ranges = [
|
155
|
-
/\A10\./,
|
156
|
+
/\A10\./, # 10.0.0.0/8
|
156
157
|
/\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
|
157
158
|
/\A192\.168\./, # 192.168.0.0/16
|
158
159
|
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
159
160
|
/\A224\./, # 224.0.0.0/4 (multicast)
|
160
|
-
/\A0\./,
|
161
|
+
/\A0\./, # 0.0.0.0/8
|
161
162
|
]
|
162
163
|
|
163
164
|
private_ranges.any? { |range| ip.match?(range) }
|
@@ -207,7 +208,7 @@ class Otto
|
|
207
208
|
|
208
209
|
# Add any header that begins with the specified prefix
|
209
210
|
if header_prefix
|
210
|
-
prefix_keys = env.keys.select {
|
211
|
+
prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
|
211
212
|
keys.concat(prefix_keys)
|
212
213
|
end
|
213
214
|
|
@@ -354,8 +355,9 @@ class Otto
|
|
354
355
|
debug_enabled = opts[:debug] || false
|
355
356
|
|
356
357
|
# Guard clause - required configuration must be present
|
357
|
-
unless available_locales && default_locale
|
358
|
-
raise ArgumentError,
|
358
|
+
unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
|
359
|
+
raise ArgumentError,
|
360
|
+
'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
|
359
361
|
end
|
360
362
|
|
361
363
|
# Check sources in order of precedence
|
@@ -1,8 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/helpers/response.rb
|
2
4
|
|
3
5
|
require_relative 'base'
|
4
6
|
|
5
7
|
class Otto
|
8
|
+
# Response helper methods providing HTTP response handling utilities
|
6
9
|
module ResponseHelpers
|
7
10
|
include Otto::BaseHelpers
|
8
11
|
|
@@ -48,7 +51,7 @@ class Otto
|
|
48
51
|
session_opts = opts.merge(
|
49
52
|
secure: true,
|
50
53
|
httponly: true,
|
51
|
-
samesite: :strict
|
54
|
+
samesite: :strict
|
52
55
|
)
|
53
56
|
|
54
57
|
# Remove expiration-related options for session cookies
|
@@ -109,9 +112,7 @@ class Otto
|
|
109
112
|
headers['content-type'] ||= content_type
|
110
113
|
|
111
114
|
# Warn if CSP header already exists but don't skip
|
112
|
-
if headers['content-security-policy']
|
113
|
-
warn 'CSP header already set, overriding with nonce-based policy'
|
114
|
-
end
|
115
|
+
warn 'CSP header already set, overriding with nonce-based policy' if headers['content-security-policy']
|
115
116
|
|
116
117
|
# Get security configuration
|
117
118
|
security_config = opts[:security_config] ||
|
@@ -1,7 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/helpers/validation.rb
|
2
4
|
|
5
|
+
require 'loofah'
|
6
|
+
require 'facets/file'
|
7
|
+
|
3
8
|
class Otto
|
4
9
|
module Security
|
10
|
+
# Validation helper methods providing input validation and sanitization
|
5
11
|
module ValidationHelpers
|
6
12
|
def validate_input(input, max_length: 1000, allow_html: false)
|
7
13
|
return input if input.nil?
|
@@ -17,9 +23,7 @@ class Otto
|
|
17
23
|
# Use Loofah for HTML sanitization and validation
|
18
24
|
unless allow_html
|
19
25
|
# Check for script injection first (these should always be rejected)
|
20
|
-
if looks_like_script_injection?(input_str)
|
21
|
-
raise Otto::Security::ValidationError, 'Dangerous content detected'
|
22
|
-
end
|
26
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected' if looks_like_script_injection?(input_str)
|
23
27
|
|
24
28
|
# Use Loofah to sanitize less dangerous HTML content
|
25
29
|
sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
|
@@ -28,9 +32,7 @@ class Otto
|
|
28
32
|
|
29
33
|
# Always check for SQL injection
|
30
34
|
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
31
|
-
if input_str.match?(pattern)
|
32
|
-
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
33
|
-
end
|
35
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
|
34
36
|
end
|
35
37
|
|
36
38
|
input_str
|
@@ -71,7 +73,7 @@ class Otto
|
|
71
73
|
dangerous_patterns = [
|
72
74
|
/javascript:/i,
|
73
75
|
/<script[^>]*>/i,
|
74
|
-
/on\w+\s*=/i,
|
76
|
+
/on\w+\s*=/i, # event handlers like onclick=
|
75
77
|
/expression\s*\(/i,
|
76
78
|
/data:.*base64/i,
|
77
79
|
]
|
data/lib/otto/mcp/auth/token.rb
CHANGED
@@ -1,8 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/auth/token.rb
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
|
3
7
|
class Otto
|
4
8
|
module MCP
|
5
9
|
module Auth
|
10
|
+
# Token-based authentication for MCP protocol endpoints
|
6
11
|
class TokenAuth
|
7
12
|
def initialize(tokens)
|
8
13
|
@tokens = Array(tokens).to_set
|
@@ -20,15 +25,14 @@ class Otto
|
|
20
25
|
def extract_token(env)
|
21
26
|
# Try Authorization header first (Bearer token)
|
22
27
|
auth_header = env['HTTP_AUTHORIZATION']
|
23
|
-
if auth_header&.start_with?('Bearer ')
|
24
|
-
return auth_header[7..]
|
25
|
-
end
|
28
|
+
return auth_header[7..] if auth_header&.start_with?('Bearer ')
|
26
29
|
|
27
30
|
# Try X-MCP-Token header
|
28
31
|
env['HTTP_X_MCP_TOKEN']
|
29
32
|
end
|
30
33
|
end
|
31
34
|
|
35
|
+
# Middleware for token authentication in MCP protocol
|
32
36
|
class TokenMiddleware
|
33
37
|
def initialize(app, security_config = nil)
|
34
38
|
@app = app
|
@@ -41,9 +45,7 @@ class Otto
|
|
41
45
|
|
42
46
|
# Get auth instance from security config
|
43
47
|
auth = @security_config&.mcp_auth
|
44
|
-
if auth && !auth.authenticate(env)
|
45
|
-
return unauthorized_response
|
46
|
-
end
|
48
|
+
return unauthorized_response if auth && !auth.authenticate(env)
|
47
49
|
|
48
50
|
@app.call(env)
|
49
51
|
end
|
@@ -58,15 +60,14 @@ class Otto
|
|
58
60
|
|
59
61
|
def unauthorized_response
|
60
62
|
body = JSON.generate({
|
61
|
-
|
63
|
+
jsonrpc: '2.0',
|
62
64
|
id: nil,
|
63
65
|
error: {
|
64
66
|
code: -32_000,
|
65
67
|
message: 'Unauthorized',
|
66
68
|
data: 'Valid token required',
|
67
69
|
},
|
68
|
-
|
69
|
-
)
|
70
|
+
})
|
70
71
|
|
71
72
|
[401, { 'content-type' => 'application/json' }, [body]]
|
72
73
|
end
|
data/lib/otto/mcp/protocol.rb
CHANGED
@@ -1,8 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/protocol.rb
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
require_relative 'registry'
|
3
7
|
|
4
8
|
class Otto
|
5
9
|
module MCP
|
10
|
+
# MCP protocol handler providing Model Context Protocol functionality
|
6
11
|
class Protocol
|
7
12
|
attr_reader :registry
|
8
13
|
|
@@ -64,14 +69,13 @@ class Otto
|
|
64
69
|
}
|
65
70
|
|
66
71
|
success_response(data['id'], {
|
67
|
-
|
72
|
+
protocolVersion: '2024-11-05',
|
68
73
|
capabilities: capabilities,
|
69
74
|
serverInfo: {
|
70
75
|
name: 'Otto MCP Server',
|
71
76
|
version: Otto::VERSION,
|
72
77
|
},
|
73
|
-
|
74
|
-
)
|
78
|
+
})
|
75
79
|
end
|
76
80
|
|
77
81
|
def handle_resources_list(data)
|
@@ -83,9 +87,7 @@ class Otto
|
|
83
87
|
params = data['params'] || {}
|
84
88
|
uri = params['uri']
|
85
89
|
|
86
|
-
unless uri
|
87
|
-
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter')
|
88
|
-
end
|
90
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
|
89
91
|
|
90
92
|
resource = @registry.read_resource(uri)
|
91
93
|
if resource
|
@@ -105,26 +107,23 @@ class Otto
|
|
105
107
|
name = params['name']
|
106
108
|
arguments = params['arguments'] || {}
|
107
109
|
|
108
|
-
unless name
|
109
|
-
return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter')
|
110
|
-
end
|
110
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter') unless name
|
111
111
|
|
112
112
|
begin
|
113
113
|
result = @registry.call_tool(name, arguments, env)
|
114
114
|
success_response(data['id'], result)
|
115
|
-
rescue StandardError =>
|
116
|
-
Otto.logger.error "[MCP] Tool call error: #{
|
117
|
-
error_response(data['id'], -32_603, 'Internal error',
|
115
|
+
rescue StandardError => e
|
116
|
+
Otto.logger.error "[MCP] Tool call error: #{e.message}"
|
117
|
+
error_response(data['id'], -32_603, 'Internal error', e.message)
|
118
118
|
end
|
119
119
|
end
|
120
120
|
|
121
121
|
def success_response(id, result)
|
122
122
|
body = JSON.generate({
|
123
|
-
|
123
|
+
jsonrpc: '2.0',
|
124
124
|
id: id,
|
125
125
|
result: result,
|
126
|
-
|
127
|
-
)
|
126
|
+
})
|
128
127
|
|
129
128
|
[200, { 'content-type' => 'application/json' }, [body]]
|
130
129
|
end
|
@@ -134,30 +133,28 @@ class Otto
|
|
134
133
|
error[:data] = data if data
|
135
134
|
|
136
135
|
body = JSON.generate({
|
137
|
-
|
136
|
+
jsonrpc: '2.0',
|
138
137
|
id: id,
|
139
138
|
error: error,
|
140
|
-
|
141
|
-
)
|
139
|
+
})
|
142
140
|
|
143
141
|
# Map JSON-RPC error codes to appropriate HTTP status codes
|
144
142
|
http_status = case code
|
145
|
-
when -
|
143
|
+
when -32_700..-32_600 # Parse error, Invalid Request, Method not found
|
146
144
|
400
|
147
|
-
when -
|
145
|
+
when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
|
148
146
|
500
|
149
|
-
when -
|
147
|
+
when -32_001 # Resource not found
|
150
148
|
404
|
151
|
-
when -
|
149
|
+
when -32_002 # Tool not found
|
152
150
|
404
|
153
|
-
when -
|
151
|
+
when -32_601 # Method not found
|
154
152
|
404
|
155
|
-
when -
|
153
|
+
when -32_602 # Invalid params
|
156
154
|
400
|
157
|
-
when -32603 # Internal error
|
158
|
-
500
|
159
155
|
else
|
160
|
-
|
156
|
+
# Default client error for unknown non-server codes; treat server-range as 500
|
157
|
+
(-32_099..-32_000).cover?(code) ? 500 : 400
|
161
158
|
end
|
162
159
|
|
163
160
|
[http_status, { 'content-type' => 'application/json' }, [body]]
|
@@ -1,3 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/rate_limiting.rb
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
|
3
7
|
require_relative '../security/rate_limiting'
|
@@ -10,6 +14,7 @@ end
|
|
10
14
|
|
11
15
|
class Otto
|
12
16
|
module MCP
|
17
|
+
# Rate limiter for MCP protocol endpoints
|
13
18
|
class RateLimiter < Otto::Security::RateLimiting
|
14
19
|
def self.configure_rack_attack!(config = {})
|
15
20
|
return unless defined?(Rack::Attack)
|
@@ -117,6 +122,7 @@ class Otto
|
|
117
122
|
end
|
118
123
|
end
|
119
124
|
|
125
|
+
# Middleware for applying rate limits to MCP protocol endpoints
|
120
126
|
class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
|
121
127
|
def initialize(app, security_config = nil)
|
122
128
|
@app = app
|
@@ -138,10 +144,9 @@ class Otto
|
|
138
144
|
|
139
145
|
# Add MCP-specific defaults
|
140
146
|
mcp_config = base_config.merge({
|
141
|
-
|
147
|
+
mcp_requests_per_minute: 60,
|
142
148
|
tool_calls_per_minute: 20,
|
143
|
-
|
144
|
-
)
|
149
|
+
})
|
145
150
|
|
146
151
|
RateLimiter.configure_rack_attack!(mcp_config)
|
147
152
|
end
|
data/lib/otto/mcp/registry.rb
CHANGED
@@ -1,5 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/registry.rb
|
4
|
+
|
1
5
|
class Otto
|
2
6
|
module MCP
|
7
|
+
# Registry for managing MCP resources and tools
|
3
8
|
class Registry
|
4
9
|
def initialize
|
5
10
|
@resources = {}
|
@@ -59,8 +64,8 @@ class Otto
|
|
59
64
|
text: content.to_s,
|
60
65
|
}],
|
61
66
|
}
|
62
|
-
rescue StandardError =>
|
63
|
-
Otto.logger.error "[MCP] Resource read error for #{uri}: #{
|
67
|
+
rescue StandardError => e
|
68
|
+
Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.message}"
|
64
69
|
nil
|
65
70
|
end
|
66
71
|
end
|