otto 1.5.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 +44 -5
- 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 +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- 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 +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- 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 +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- 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 +30 -33
- 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 -376
- 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 +15 -11
- 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 +16 -0
- data/lib/otto/security/validator.rb +8 -292
- 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 +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- 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
@@ -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] ||
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/helpers/validation.rb
|
4
|
+
|
5
|
+
require 'loofah'
|
6
|
+
require 'facets/file'
|
7
|
+
|
8
|
+
class Otto
|
9
|
+
module Security
|
10
|
+
# Validation helper methods providing input validation and sanitization
|
11
|
+
module ValidationHelpers
|
12
|
+
def validate_input(input, max_length: 1000, allow_html: false)
|
13
|
+
return input if input.nil?
|
14
|
+
|
15
|
+
input_str = input.to_s
|
16
|
+
return input_str if input_str.empty?
|
17
|
+
|
18
|
+
# Check length
|
19
|
+
if input_str.length > max_length
|
20
|
+
raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
|
21
|
+
end
|
22
|
+
|
23
|
+
# Use Loofah for HTML sanitization and validation
|
24
|
+
unless allow_html
|
25
|
+
# Check for script injection first (these should always be rejected)
|
26
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected' if looks_like_script_injection?(input_str)
|
27
|
+
|
28
|
+
# Use Loofah to sanitize less dangerous HTML content
|
29
|
+
sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
|
30
|
+
input_str = sanitized_input
|
31
|
+
end
|
32
|
+
|
33
|
+
# Always check for SQL injection
|
34
|
+
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
35
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
|
36
|
+
end
|
37
|
+
|
38
|
+
input_str
|
39
|
+
end
|
40
|
+
|
41
|
+
def sanitize_filename(filename)
|
42
|
+
return nil if filename.nil?
|
43
|
+
return 'file' if filename.empty?
|
44
|
+
|
45
|
+
# Use Facets File.sanitize for basic filesystem-safe filename
|
46
|
+
clean_name = File.sanitize(filename.to_s)
|
47
|
+
|
48
|
+
# Handle edge cases and improve on Facets behavior to match test expectations
|
49
|
+
if clean_name.nil? || clean_name.empty?
|
50
|
+
clean_name = 'file'
|
51
|
+
else
|
52
|
+
# Additional cleanup that Facets doesn't do but our tests expect
|
53
|
+
clean_name = clean_name.gsub(/_{2,}/, '_') # Collapse multiple underscores
|
54
|
+
clean_name = clean_name.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
|
55
|
+
clean_name = 'file' if clean_name.empty? # Handle case where only underscores remain
|
56
|
+
end
|
57
|
+
|
58
|
+
# Ensure reasonable length (255 is filesystem limit, leave some padding)
|
59
|
+
clean_name = clean_name[0..99] if clean_name.length > 100
|
60
|
+
|
61
|
+
clean_name
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Check if content looks like it contains HTML tags or entities
|
67
|
+
def contains_html_like_content?(content)
|
68
|
+
content.match?(/[<>&]/) || content.match?(/&\w+;/)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Detect likely script injection attempts that should be rejected
|
72
|
+
def looks_like_script_injection?(content)
|
73
|
+
dangerous_patterns = [
|
74
|
+
/javascript:/i,
|
75
|
+
/<script[^>]*>/i,
|
76
|
+
/on\w+\s*=/i, # event handlers like onclick=
|
77
|
+
/expression\s*\(/i,
|
78
|
+
/data:.*base64/i,
|
79
|
+
]
|
80
|
+
|
81
|
+
dangerous_patterns.any? { |pattern| content.match?(pattern) }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/auth/token.rb
|
4
|
+
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
class Otto
|
8
|
+
module MCP
|
9
|
+
module Auth
|
10
|
+
# Token-based authentication for MCP protocol endpoints
|
11
|
+
class TokenAuth
|
12
|
+
def initialize(tokens)
|
13
|
+
@tokens = Array(tokens).to_set
|
14
|
+
end
|
15
|
+
|
16
|
+
def authenticate(env)
|
17
|
+
token = extract_token(env)
|
18
|
+
return false unless token
|
19
|
+
|
20
|
+
@tokens.include?(token)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def extract_token(env)
|
26
|
+
# Try Authorization header first (Bearer token)
|
27
|
+
auth_header = env['HTTP_AUTHORIZATION']
|
28
|
+
return auth_header[7..] if auth_header&.start_with?('Bearer ')
|
29
|
+
|
30
|
+
# Try X-MCP-Token header
|
31
|
+
env['HTTP_X_MCP_TOKEN']
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Middleware for token authentication in MCP protocol
|
36
|
+
class TokenMiddleware
|
37
|
+
def initialize(app, security_config = nil)
|
38
|
+
@app = app
|
39
|
+
@security_config = security_config
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(env)
|
43
|
+
# Only apply to MCP endpoints
|
44
|
+
return @app.call(env) unless mcp_endpoint?(env)
|
45
|
+
|
46
|
+
# Get auth instance from security config
|
47
|
+
auth = @security_config&.mcp_auth
|
48
|
+
return unauthorized_response if auth && !auth.authenticate(env)
|
49
|
+
|
50
|
+
@app.call(env)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def mcp_endpoint?(env)
|
56
|
+
endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
|
57
|
+
path = env['PATH_INFO'].to_s
|
58
|
+
path.start_with?(endpoint)
|
59
|
+
end
|
60
|
+
|
61
|
+
def unauthorized_response
|
62
|
+
body = JSON.generate({
|
63
|
+
jsonrpc: '2.0',
|
64
|
+
id: nil,
|
65
|
+
error: {
|
66
|
+
code: -32_000,
|
67
|
+
message: 'Unauthorized',
|
68
|
+
data: 'Valid token required',
|
69
|
+
},
|
70
|
+
})
|
71
|
+
|
72
|
+
[401, { 'content-type' => 'application/json' }, [body]]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/mcp/protocol.rb
|
4
|
+
|
5
|
+
require 'json'
|
6
|
+
require_relative 'registry'
|
7
|
+
|
8
|
+
class Otto
|
9
|
+
module MCP
|
10
|
+
# MCP protocol handler providing Model Context Protocol functionality
|
11
|
+
class Protocol
|
12
|
+
attr_reader :registry
|
13
|
+
|
14
|
+
def initialize(otto_instance)
|
15
|
+
@otto = otto_instance
|
16
|
+
@registry = Registry.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def handle_request(env)
|
20
|
+
request = Rack::Request.new(env)
|
21
|
+
|
22
|
+
unless request.post? && request.content_type&.include?('application/json')
|
23
|
+
return error_response(nil, -32_600, 'Invalid Request', 'Only JSON-RPC POST requests supported')
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
body = request.body.read
|
28
|
+
data = JSON.parse(body)
|
29
|
+
rescue JSON::ParserError
|
30
|
+
return error_response(nil, -32_700, 'Parse error', 'Invalid JSON')
|
31
|
+
end
|
32
|
+
|
33
|
+
unless valid_jsonrpc_request?(data)
|
34
|
+
return error_response(data['id'], -32_600, 'Invalid Request', 'Missing jsonrpc, method, or id fields')
|
35
|
+
end
|
36
|
+
|
37
|
+
case data['method']
|
38
|
+
when 'initialize'
|
39
|
+
handle_initialize(data)
|
40
|
+
when 'resources/list'
|
41
|
+
handle_resources_list(data)
|
42
|
+
when 'resources/read'
|
43
|
+
handle_resources_read(data)
|
44
|
+
when 'tools/list'
|
45
|
+
handle_tools_list(data)
|
46
|
+
when 'tools/call'
|
47
|
+
handle_tools_call(data, env)
|
48
|
+
else
|
49
|
+
error_response(data['id'], -32_601, 'Method not found', "Unknown method: #{data['method']}")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def valid_jsonrpc_request?(data)
|
56
|
+
data.is_a?(Hash) &&
|
57
|
+
data['jsonrpc'] == '2.0' &&
|
58
|
+
data['method'].is_a?(String) &&
|
59
|
+
data.key?('id')
|
60
|
+
end
|
61
|
+
|
62
|
+
def handle_initialize(data)
|
63
|
+
capabilities = {
|
64
|
+
resources: {
|
65
|
+
subscribe: false,
|
66
|
+
listChanged: false,
|
67
|
+
},
|
68
|
+
tools: {},
|
69
|
+
}
|
70
|
+
|
71
|
+
success_response(data['id'], {
|
72
|
+
protocolVersion: '2024-11-05',
|
73
|
+
capabilities: capabilities,
|
74
|
+
serverInfo: {
|
75
|
+
name: 'Otto MCP Server',
|
76
|
+
version: Otto::VERSION,
|
77
|
+
},
|
78
|
+
})
|
79
|
+
end
|
80
|
+
|
81
|
+
def handle_resources_list(data)
|
82
|
+
resources = @registry.list_resources
|
83
|
+
success_response(data['id'], { resources: resources })
|
84
|
+
end
|
85
|
+
|
86
|
+
def handle_resources_read(data)
|
87
|
+
params = data['params'] || {}
|
88
|
+
uri = params['uri']
|
89
|
+
|
90
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
|
91
|
+
|
92
|
+
resource = @registry.read_resource(uri)
|
93
|
+
if resource
|
94
|
+
success_response(data['id'], resource)
|
95
|
+
else
|
96
|
+
error_response(data['id'], -32_001, 'Resource not found', "Resource not found: #{uri}")
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def handle_tools_list(data)
|
101
|
+
tools = @registry.list_tools
|
102
|
+
success_response(data['id'], { tools: tools })
|
103
|
+
end
|
104
|
+
|
105
|
+
def handle_tools_call(data, env)
|
106
|
+
params = data['params'] || {}
|
107
|
+
name = params['name']
|
108
|
+
arguments = params['arguments'] || {}
|
109
|
+
|
110
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter') unless name
|
111
|
+
|
112
|
+
begin
|
113
|
+
result = @registry.call_tool(name, arguments, env)
|
114
|
+
success_response(data['id'], result)
|
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
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def success_response(id, result)
|
122
|
+
body = JSON.generate({
|
123
|
+
jsonrpc: '2.0',
|
124
|
+
id: id,
|
125
|
+
result: result,
|
126
|
+
})
|
127
|
+
|
128
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
129
|
+
end
|
130
|
+
|
131
|
+
def error_response(id, code, message, data = nil)
|
132
|
+
error = { code: code, message: message }
|
133
|
+
error[:data] = data if data
|
134
|
+
|
135
|
+
body = JSON.generate({
|
136
|
+
jsonrpc: '2.0',
|
137
|
+
id: id,
|
138
|
+
error: error,
|
139
|
+
})
|
140
|
+
|
141
|
+
# Map JSON-RPC error codes to appropriate HTTP status codes
|
142
|
+
http_status = case code
|
143
|
+
when -32_700..-32_600 # Parse error, Invalid Request, Method not found
|
144
|
+
400
|
145
|
+
when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
|
146
|
+
500
|
147
|
+
when -32_001 # Resource not found
|
148
|
+
404
|
149
|
+
when -32_002 # Tool not found
|
150
|
+
404
|
151
|
+
when -32_601 # Method not found
|
152
|
+
404
|
153
|
+
when -32_602 # Invalid params
|
154
|
+
400
|
155
|
+
else
|
156
|
+
# Default client error for unknown non-server codes; treat server-range as 500
|
157
|
+
(-32_099..-32_000).cover?(code) ? 500 : 400
|
158
|
+
end
|
159
|
+
|
160
|
+
[http_status, { 'content-type' => 'application/json' }, [body]]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|