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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -1,111 +1,16 @@
1
- require 'json'
1
+ # frozen_string_literal: true
2
2
 
3
- begin
4
- require 'rack/attack'
5
- rescue LoadError
6
- # rack-attack is optional - graceful fallback
7
- end
3
+ # lib/otto/security/rate_limiting.rb
4
+ #
5
+ # Index file for rate limiting components
6
+ # Provides backward compatibility for existing rate limiting usage
7
+
8
+ require_relative 'rate_limiter'
9
+ require_relative 'middleware/rate_limit_middleware'
8
10
 
9
11
  class Otto
10
12
  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
13
+ # Backward compatibility alias
14
+ RateLimitMiddleware = Middleware::RateLimitMiddleware
110
15
  end
111
16
  end
@@ -1,260 +1,15 @@
1
- # lib/otto/security/validator.rb
1
+ # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'cgi'
5
- require 'loofah'
6
- require 'facets/file'
3
+ # lib/otto/security/validator.rb
4
+ #
5
+ # Index file for validation middleware
6
+ # Provides backward compatibility for existing validation usage
7
7
 
8
- require_relative '../helpers/validation'
8
+ require_relative 'middleware/validation_middleware'
9
9
 
10
10
  class Otto
11
11
  module Security
12
- # ValidationMiddleware provides input validation and sanitization for web requests
13
- # Uses Loofah for HTML/XSS sanitization and Facets for filename sanitization
14
- class ValidationMiddleware
15
- # Character validation patterns
16
- INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
17
- NULL_BYTE = /\0/
18
-
19
- # HTML/XSS sanitization is handled by Loofah library for better security coverage
20
-
21
- SQL_INJECTION_PATTERNS = [
22
- /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
23
- /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
24
- /(or|and)\s+\w+\s*=\s*\w+/i,
25
- /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
26
- ].freeze
27
-
28
- def initialize(app, config = nil)
29
- @app = app
30
- @config = config || Otto::Security::Config.new
31
- end
32
-
33
- def call(env)
34
- return @app.call(env) unless @config.input_validation
35
-
36
- request = Rack::Request.new(env)
37
-
38
- begin
39
- # Validate request size
40
- validate_request_size(request)
41
-
42
- # Validate content type
43
- validate_content_type(request)
44
-
45
- # Validate and sanitize parameters
46
- begin
47
- validate_parameters(request) if request.params
48
- rescue Rack::QueryParser::QueryLimitError => ex
49
- # Handle Rack's built-in query parsing limits
50
- raise Otto::Security::ValidationError, "Parameter structure too complex: #{ex.message}"
51
- end
52
-
53
- # Validate headers
54
- validate_headers(request)
55
-
56
- @app.call(env)
57
- rescue Otto::Security::ValidationError => ex
58
- validation_error_response(ex.message)
59
- rescue Otto::Security::RequestTooLargeError => ex
60
- request_too_large_response(ex.message)
61
- end
62
- end
63
-
64
- private
65
-
66
- def validate_request_size(request)
67
- content_length = request.env['CONTENT_LENGTH']
68
- @config.validate_request_size(content_length)
69
- end
70
-
71
- def validate_content_type(request)
72
- content_type = request.env['CONTENT_TYPE']
73
- return unless content_type
74
-
75
- # Block dangerous content types
76
- dangerous_types = [
77
- 'application/x-shockwave-flash',
78
- 'application/x-silverlight-app',
79
- 'text/vbscript',
80
- 'application/vbscript',
81
- ]
82
-
83
- if dangerous_types.any? { |type| content_type.downcase.include?(type) }
84
- raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
85
- end
86
- end
87
-
88
- def validate_parameters(request)
89
- validate_param_structure(request.params, 0)
90
- sanitize_params(request.params)
91
- end
92
-
93
- def validate_param_structure(params, depth = 0)
94
- if depth >= @config.max_param_depth
95
- raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
96
- end
97
-
98
- case params
99
- when Hash
100
- if params.keys.length > @config.max_param_keys
101
- raise Otto::Security::ValidationError, "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
102
- end
103
-
104
- params.each do |key, value|
105
- validate_param_key(key)
106
- validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
107
- end
108
- when Array
109
- if params.length > @config.max_param_keys
110
- raise Otto::Security::ValidationError, "Too many array elements (#{params.length} > #{@config.max_param_keys})"
111
- end
112
-
113
- params.each do |value|
114
- validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
115
- end
116
- end
117
- end
118
-
119
- def validate_param_key(key)
120
- key_str = key.to_s
121
-
122
- # Check for dangerous characters in parameter names using shared patterns
123
- if key_str.match?(NULL_BYTE) || key_str.match?(INVALID_CHARACTERS)
124
- raise Otto::Security::ValidationError, "Invalid characters in parameter name: #{key_str}"
125
- end
126
-
127
- # Check for suspiciously long parameter names
128
- if key_str.length > 256
129
- raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
130
- end
131
- end
132
-
133
- def sanitize_params(params)
134
- case params
135
- when Hash
136
- params.each do |key, value|
137
- params[key] = sanitize_value(value)
138
- end
139
- when Array
140
- params.map! { |value| sanitize_value(value) }
141
- else
142
- sanitize_value(params)
143
- end
144
- end
145
-
146
- def sanitize_value(value)
147
- return value unless value.is_a?(String)
148
-
149
- # Check for extremely long values first
150
- if value.length > 10_000
151
- raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
152
- end
153
-
154
- # Start with the original value
155
- original = value.dup
156
-
157
- # Check for null bytes first (these should be rejected, not sanitized)
158
- if original.match?(NULL_BYTE)
159
- raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
160
- end
161
-
162
- # Check for script injection first (these should always be rejected)
163
- if looks_like_script_injection?(original)
164
- raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
165
- end
166
-
167
- # Use Loofah to sanitize HTML/XSS content for less dangerous HTML
168
- # Loofah.fragment removes dangerous HTML but preserves safe content
169
- sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
170
-
171
- # Remove control characters (sanitize, don't block)
172
- sanitized = sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
173
-
174
- # Check for SQL injection patterns
175
- SQL_INJECTION_PATTERNS.each do |pattern|
176
- if sanitized.match?(pattern)
177
- raise Otto::Security::ValidationError, 'Potential SQL injection detected'
178
- end
179
- end
180
-
181
- sanitized
182
- end
183
-
184
- include ValidationHelpers
185
-
186
- private
187
-
188
- def validate_headers(request)
189
- # Check for dangerous headers
190
- dangerous_headers = %w[
191
- HTTP_X_FORWARDED_HOST
192
- HTTP_X_ORIGINAL_URL
193
- HTTP_X_REWRITE_URL
194
- HTTP_DESTINATION
195
- HTTP_UPGRADE_INSECURE_REQUESTS
196
- ]
197
-
198
- dangerous_headers.each do |header|
199
- value = request.env[header]
200
- next unless value
201
-
202
- # Basic validation - no null bytes or control characters
203
- if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
204
- raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
205
- end
206
- end
207
-
208
- # Validate User-Agent length
209
- user_agent = request.env['HTTP_USER_AGENT']
210
- if user_agent && user_agent.length > 1000
211
- raise Otto::Security::ValidationError, 'User-Agent header too long'
212
- end
213
-
214
- # Validate Referer header
215
- referer = request.env['HTTP_REFERER']
216
- if referer && referer.length > 2000
217
- raise Otto::Security::ValidationError, 'Referer header too long'
218
- end
219
- end
220
-
221
- def validation_error_response(message)
222
- [
223
- 400,
224
- {
225
- 'content-type' => 'application/json',
226
- 'content-length' => validation_error_body(message).bytesize.to_s,
227
- },
228
- [validation_error_body(message)],
229
- ]
230
- end
231
-
232
- def request_too_large_response(message)
233
- [
234
- 413,
235
- {
236
- 'content-type' => 'application/json',
237
- 'content-length' => request_too_large_body(message).bytesize.to_s,
238
- },
239
- [request_too_large_body(message)],
240
- ]
241
- end
242
-
243
- def validation_error_body(message)
244
- require 'json'
245
- {
246
- error: 'Validation failed',
247
- message: message,
248
- }.to_json
249
- end
250
-
251
- def request_too_large_body(message)
252
- require 'json'
253
- {
254
- error: 'Request too large',
255
- message: message,
256
- }.to_json
257
- end
258
- end
12
+ # Backward compatibility alias
13
+ ValidationMiddleware = Middleware::ValidationMiddleware
259
14
  end
260
15
  end
data/lib/otto/static.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/static.rb
2
4
 
3
5
  class Otto
6
+ # Static response utilities for common HTTP responses
4
7
  module Static
5
8
  extend self
6
9
 
data/lib/otto/utils.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/utils.rb
4
+
5
+ class Otto
6
+ # Utility methods for common operations and helpers
7
+ module Utils
8
+ extend self
9
+
10
+ def yes?(value)
11
+ !value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
12
+ end
13
+ end
14
+ end
data/lib/otto/version.rb CHANGED
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/version.rb
2
4
 
3
5
  class Otto
4
- VERSION = '1.6.0'.freeze
6
+ VERSION = '2.0.0.pre1'
5
7
  end