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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -5
  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 +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  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 +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../../helpers/validation'
5
+
6
+ class Otto
7
+ module Security
8
+ module Middleware
9
+ # ValidationMiddleware provides input validation and sanitization for web requests
10
+ # Uses Loofah for HTML/XSS sanitization and Facets for filename sanitization
11
+ class ValidationMiddleware
12
+ # Character validation patterns
13
+ INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
14
+ NULL_BYTE = /\0/
15
+
16
+ # HTML/XSS sanitization is handled by Loofah library for better security coverage
17
+
18
+ SQL_INJECTION_PATTERNS = [
19
+ /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
20
+ /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
21
+ /(or|and)\s+\w+\s*=\s*\w+/i,
22
+ /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
23
+ ].freeze
24
+
25
+ def initialize(app, config = nil)
26
+ @app = app
27
+ @config = config || Otto::Security::Config.new
28
+ end
29
+
30
+ def call(env)
31
+ return @app.call(env) unless @config.input_validation
32
+
33
+ request = Rack::Request.new(env)
34
+
35
+ begin
36
+ # Validate request size
37
+ validate_request_size(request)
38
+
39
+ # Validate content type
40
+ validate_content_type(request)
41
+
42
+ # Validate and sanitize parameters
43
+ begin
44
+ validate_parameters(request) if request.params
45
+ rescue Rack::QueryParser::QueryLimitError => e
46
+ # Handle Rack's built-in query parsing limits
47
+ raise Otto::Security::ValidationError, "Parameter structure too complex: #{e.message}"
48
+ end
49
+
50
+ # Validate headers
51
+ validate_headers(request)
52
+
53
+ @app.call(env)
54
+ rescue Otto::Security::ValidationError => e
55
+ validation_error_response(e.message)
56
+ rescue Otto::Security::RequestTooLargeError => e
57
+ request_too_large_response(e.message)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def validate_request_size(request)
64
+ content_length = request.env['CONTENT_LENGTH']
65
+ @config.validate_request_size(content_length)
66
+ end
67
+
68
+ def validate_content_type(request)
69
+ content_type = request.env['CONTENT_TYPE']
70
+ return unless content_type
71
+
72
+ # Block dangerous content types
73
+ dangerous_types = [
74
+ 'application/x-shockwave-flash',
75
+ 'application/x-silverlight-app',
76
+ 'text/vbscript',
77
+ 'application/vbscript',
78
+ ]
79
+
80
+ return unless dangerous_types.any? { |type| content_type.downcase.include?(type) }
81
+
82
+ raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
83
+ end
84
+
85
+ def validate_parameters(request)
86
+ validate_param_structure(request.params, 0)
87
+ sanitize_params(request.params)
88
+ end
89
+
90
+ def validate_param_structure(params, depth = 0)
91
+ if depth >= @config.max_param_depth
92
+ raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
93
+ end
94
+
95
+ case params
96
+ when Hash
97
+ if params.keys.length > @config.max_param_keys
98
+ raise Otto::Security::ValidationError,
99
+ "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
100
+ end
101
+
102
+ params.each do |key, value|
103
+ validate_param_key(key)
104
+ validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
105
+ end
106
+ when Array
107
+ if params.length > @config.max_param_keys
108
+ raise Otto::Security::ValidationError,
109
+ "Too many array elements (#{params.length} > #{@config.max_param_keys})"
110
+ end
111
+
112
+ params.each do |value|
113
+ validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
114
+ end
115
+ end
116
+ end
117
+
118
+ def validate_param_key(key)
119
+ key_str = key.to_s
120
+
121
+ # Check for dangerous characters in parameter names using shared patterns
122
+ if key_str.match?(NULL_BYTE) || key_str.match?(INVALID_CHARACTERS)
123
+ raise Otto::Security::ValidationError, "Invalid characters in parameter name: #{key_str}"
124
+ end
125
+
126
+ # Check for suspiciously long parameter names
127
+ return unless key_str.length > 256
128
+
129
+ raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
130
+ end
131
+
132
+ def sanitize_params(params)
133
+ case params
134
+ when Hash
135
+ params.each do |key, value|
136
+ params[key] = sanitize_value(value)
137
+ end
138
+ when Array
139
+ params.map! { |value| sanitize_value(value) }
140
+ else
141
+ sanitize_value(params)
142
+ end
143
+ end
144
+
145
+ def sanitize_value(value)
146
+ return value unless value.is_a?(String)
147
+
148
+ # Check for extremely long values first
149
+ if value.length > 10_000
150
+ raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
151
+ end
152
+
153
+ # Start with the original value
154
+ original = value.dup
155
+
156
+ # Check for null bytes first (these should be rejected, not sanitized)
157
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter' if original.match?(NULL_BYTE)
158
+
159
+ # Check for script injection first (these should always be rejected)
160
+ if looks_like_script_injection?(original)
161
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
162
+ end
163
+
164
+ # Use Loofah to sanitize HTML/XSS content for less dangerous HTML
165
+ # Loofah.fragment removes dangerous HTML but preserves safe content
166
+ sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
167
+
168
+ # Remove control characters (sanitize, don't block)
169
+ sanitized = sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
170
+
171
+ # Check for SQL injection patterns
172
+ SQL_INJECTION_PATTERNS.each do |pattern|
173
+ raise Otto::Security::ValidationError, 'Potential SQL injection detected' if sanitized.match?(pattern)
174
+ end
175
+
176
+ sanitized
177
+ end
178
+
179
+ include Otto::Security::ValidationHelpers
180
+
181
+ def validate_headers(request)
182
+ # Check for dangerous headers
183
+ dangerous_headers = %w[
184
+ HTTP_X_FORWARDED_HOST
185
+ HTTP_X_ORIGINAL_URL
186
+ HTTP_X_REWRITE_URL
187
+ HTTP_DESTINATION
188
+ HTTP_UPGRADE_INSECURE_REQUESTS
189
+ ]
190
+
191
+ dangerous_headers.each do |header|
192
+ value = request.env[header]
193
+ next unless value
194
+
195
+ # Basic validation - no null bytes or control characters
196
+ if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
197
+ raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
198
+ end
199
+ end
200
+
201
+ # Validate User-Agent length
202
+ user_agent = request.env['HTTP_USER_AGENT']
203
+ raise Otto::Security::ValidationError, 'User-Agent header too long' if user_agent && user_agent.length > 1000
204
+
205
+ # Validate Referer header
206
+ referer = request.env['HTTP_REFERER']
207
+ return unless referer && referer.length > 2000
208
+
209
+ raise Otto::Security::ValidationError, 'Referer header too long'
210
+ end
211
+
212
+ def validation_error_response(message)
213
+ [
214
+ 400,
215
+ {
216
+ 'content-type' => 'application/json',
217
+ 'content-length' => validation_error_body(message).bytesize.to_s,
218
+ },
219
+ [validation_error_body(message)],
220
+ ]
221
+ end
222
+
223
+ def request_too_large_response(message)
224
+ [
225
+ 413,
226
+ {
227
+ 'content-type' => 'application/json',
228
+ 'content-length' => request_too_large_body(message).bytesize.to_s,
229
+ },
230
+ [request_too_large_body(message)],
231
+ ]
232
+ end
233
+
234
+ def validation_error_body(message)
235
+ require 'json'
236
+ {
237
+ error: 'Validation failed',
238
+ message: message,
239
+ }.to_json
240
+ end
241
+
242
+ def request_too_large_body(message)
243
+ require 'json'
244
+ {
245
+ error: 'Request too large',
246
+ message: message,
247
+ }.to_json
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/rate_limiter.rb
4
+
5
+ require 'json'
6
+
7
+ begin
8
+ require 'rack/attack'
9
+ rescue LoadError
10
+ # rack-attack is optional - graceful fallback
11
+ end
12
+
13
+ class Otto
14
+ module Security
15
+ # Rate limiting implementation using Rack::Attack
16
+ class RateLimiting
17
+ def self.configure_rack_attack!(config = {})
18
+ return unless defined?(Rack::Attack)
19
+
20
+ # Use provided cache store or default
21
+ Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
22
+
23
+ # Default rules
24
+ default_requests_per_minute = config.fetch(:requests_per_minute, 100)
25
+
26
+ # General request throttling
27
+ Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
28
+ request.ip unless request.path.start_with?('/_') # Skip internal paths by default
29
+ end
30
+
31
+ # Apply custom rules if provided
32
+ if config[:custom_rules]
33
+ config[:custom_rules].each do |name, rule_config|
34
+ limit = rule_config[:limit]
35
+ period = rule_config[:period] || 60
36
+ condition = rule_config[:condition]
37
+
38
+ Rack::Attack.throttle(name.to_s, limit: limit, period: period) do |request|
39
+ if condition
40
+ request.ip if condition.call(request)
41
+ else
42
+ request.ip
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # Custom response for rate limited requests
49
+ Rack::Attack.throttled_responder = lambda do |request|
50
+ match_data = request.env['rack.attack.match_data']
51
+ now = match_data[:epoch_time]
52
+
53
+ headers = {
54
+ 'content-type' => 'application/json',
55
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
+ }
57
+
58
+ # Check if request expects JSON
59
+ accept_header = request.env['HTTP_ACCEPT'].to_s
60
+ if accept_header.include?('application/json')
61
+ error_response = {
62
+ error: 'Rate limit exceeded',
63
+ message: 'Too many requests',
64
+ retry_after: headers['retry-after'].to_i,
65
+ limit: match_data[:limit],
66
+ period: match_data[:period],
67
+ }
68
+ [429, headers, [JSON.generate(error_response)]]
69
+ else
70
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
71
+ headers['content-type'] = 'text/plain'
72
+ [429, headers, [body]]
73
+ end
74
+ end
75
+
76
+ # Log blocked requests if ActiveSupport is available
77
+ return unless defined?(ActiveSupport::Notifications)
78
+
79
+ ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
80
+ req = payload[:request]
81
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
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'
10
+
11
+ class Otto
12
+ module Security
13
+ # Backward compatibility alias
14
+ RateLimitMiddleware = Middleware::RateLimitMiddleware
15
+ end
16
+ end
@@ -1,299 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/security/validator.rb
4
+ #
5
+ # Index file for validation middleware
6
+ # Provides backward compatibility for existing validation usage
2
7
 
3
- require 'json'
4
- require 'cgi'
8
+ require_relative 'middleware/validation_middleware'
5
9
 
6
10
  class Otto
7
11
  module Security
8
- # ValidationMiddleware provides input validation and sanitization for web requests
9
- class ValidationMiddleware
10
- # Character validation patterns
11
- INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
12
- NULL_BYTE = /\0/
13
-
14
- DANGEROUS_PATTERNS = [
15
- /<script[^>]*>/i, # Script tags
16
- /javascript:/i, # JavaScript protocol
17
- /data:.*base64/i, # Data URLs with base64
18
- /on\w+\s*=/i, # Event handlers
19
- /expression\s*\(/i, # CSS expressions
20
- /url\s*\(/i, # CSS url() functions
21
- NULL_BYTE, # Null bytes
22
- INVALID_CHARACTERS, # Control characters and extended ASCII
23
- ].freeze
24
-
25
- SQL_INJECTION_PATTERNS = [
26
- /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
27
- /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
28
- /(or|and)\s+\w+\s*=\s*\w+/i,
29
- /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
30
- ].freeze
31
-
32
- def initialize(app, config = nil)
33
- @app = app
34
- @config = config || Otto::Security::Config.new
35
- end
36
-
37
- def call(env)
38
- return @app.call(env) unless @config.input_validation
39
-
40
- request = Rack::Request.new(env)
41
-
42
- begin
43
- # Validate request size
44
- validate_request_size(request)
45
-
46
- # Validate content type
47
- validate_content_type(request)
48
-
49
- # Validate and sanitize parameters
50
- begin
51
- validate_parameters(request) if request.params
52
- rescue Rack::QueryParser::QueryLimitError => ex
53
- # Handle Rack's built-in query parsing limits
54
- raise Otto::Security::ValidationError, "Parameter structure too complex: #{ex.message}"
55
- end
56
-
57
- # Validate headers
58
- validate_headers(request)
59
-
60
- @app.call(env)
61
- rescue Otto::Security::ValidationError => ex
62
- validation_error_response(ex.message)
63
- rescue Otto::Security::RequestTooLargeError => ex
64
- request_too_large_response(ex.message)
65
- end
66
- end
67
-
68
- private
69
-
70
- def validate_request_size(request)
71
- content_length = request.env['CONTENT_LENGTH']
72
- @config.validate_request_size(content_length)
73
- end
74
-
75
- def validate_content_type(request)
76
- content_type = request.env['CONTENT_TYPE']
77
- return unless content_type
78
-
79
- # Block dangerous content types
80
- dangerous_types = [
81
- 'application/x-shockwave-flash',
82
- 'application/x-silverlight-app',
83
- 'text/vbscript',
84
- 'application/vbscript',
85
- ]
86
-
87
- if dangerous_types.any? { |type| content_type.downcase.include?(type) }
88
- raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
89
- end
90
- end
91
-
92
- def validate_parameters(request)
93
- validate_param_structure(request.params, 0)
94
- sanitize_params(request.params)
95
- end
96
-
97
- def validate_param_structure(params, depth = 0)
98
- if depth > @config.max_param_depth
99
- raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
100
- end
101
-
102
- case params
103
- when Hash
104
- if params.keys.length > @config.max_param_keys
105
- raise Otto::Security::ValidationError, "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
106
- end
107
-
108
- params.each do |key, value|
109
- validate_param_key(key)
110
- validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
111
- end
112
- when Array
113
- if params.length > @config.max_param_keys
114
- raise Otto::Security::ValidationError, "Too many array elements (#{params.length} > #{@config.max_param_keys})"
115
- end
116
-
117
- params.each do |value|
118
- validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
119
- end
120
- end
121
- end
122
-
123
- def validate_param_key(key)
124
- key_str = key.to_s
125
-
126
- # Check for dangerous characters in parameter names using shared patterns
127
- if key_str.match?(NULL_BYTE) || key_str.match?(INVALID_CHARACTERS)
128
- raise Otto::Security::ValidationError, "Invalid characters in parameter name: #{key_str}"
129
- end
130
-
131
- # Check for suspiciously long parameter names
132
- if key_str.length > 256
133
- raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
134
- end
135
- end
136
-
137
- def sanitize_params(params)
138
- case params
139
- when Hash
140
- params.each do |key, value|
141
- params[key] = sanitize_value(value)
142
- end
143
- when Array
144
- params.map! { |value| sanitize_value(value) }
145
- else
146
- sanitize_value(params)
147
- end
148
- end
149
-
150
- def sanitize_value(value)
151
- return value unless value.is_a?(String)
152
-
153
- # Check for dangerous patterns
154
- DANGEROUS_PATTERNS.each do |pattern|
155
- if value.match?(pattern)
156
- raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
157
- end
158
- end
159
-
160
- # Check for SQL injection patterns
161
- SQL_INJECTION_PATTERNS.each do |pattern|
162
- if value.match?(pattern)
163
- raise Otto::Security::ValidationError, 'Potential SQL injection detected'
164
- end
165
- end
166
-
167
- # Check for extremely long values
168
- if value.length > 10_000
169
- raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
170
- end
171
-
172
- # Basic sanitization - remove null bytes and control characters
173
- sanitized = value.gsub(/\0/, '').gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
174
-
175
- # Additional sanitization for common attack vectors
176
- sanitized = sanitized.gsub(/<!--.*?-->/m, '') # Remove HTML comments
177
- sanitized.gsub(/<!\[CDATA\[.*?\]\]>/m, '') # Remove CDATA sections
178
- end
179
-
180
- def validate_headers(request)
181
- # Check for dangerous headers
182
- dangerous_headers = %w[
183
- HTTP_X_FORWARDED_HOST
184
- HTTP_X_ORIGINAL_URL
185
- HTTP_X_REWRITE_URL
186
- HTTP_DESTINATION
187
- HTTP_UPGRADE_INSECURE_REQUESTS
188
- ]
189
-
190
- dangerous_headers.each do |header|
191
- value = request.env[header]
192
- next unless value
193
-
194
- # Basic validation - no null bytes or control characters
195
- if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
196
- raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
197
- end
198
- end
199
-
200
- # Validate User-Agent length
201
- user_agent = request.env['HTTP_USER_AGENT']
202
- if user_agent && user_agent.length > 1000
203
- raise Otto::Security::ValidationError, 'User-Agent header too long'
204
- end
205
-
206
- # Validate Referer header
207
- referer = request.env['HTTP_REFERER']
208
- if referer && referer.length > 2000
209
- raise Otto::Security::ValidationError, 'Referer header too long'
210
- end
211
- end
212
-
213
- def validation_error_response(message)
214
- [
215
- 400,
216
- {
217
- 'content-type' => 'application/json',
218
- 'content-length' => validation_error_body(message).bytesize.to_s,
219
- },
220
- [validation_error_body(message)],
221
- ]
222
- end
223
-
224
- def request_too_large_response(message)
225
- [
226
- 413,
227
- {
228
- 'content-type' => 'application/json',
229
- 'content-length' => request_too_large_body(message).bytesize.to_s,
230
- },
231
- [request_too_large_body(message)],
232
- ]
233
- end
234
-
235
- def validation_error_body(message)
236
- require 'json'
237
- {
238
- error: 'Validation failed',
239
- message: message,
240
- }.to_json
241
- end
242
-
243
- def request_too_large_body(message)
244
- require 'json'
245
- {
246
- error: 'Request too large',
247
- message: message,
248
- }.to_json
249
- end
250
- end
251
-
252
- module ValidationHelpers
253
- def validate_input(input, max_length: 1000, allow_html: false)
254
- return input if input.nil? || input.empty?
255
-
256
- input_str = input.to_s
257
-
258
- # Check length
259
- if input_str.length > max_length
260
- raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
261
- end
262
-
263
- # Check for dangerous patterns unless HTML is allowed
264
- unless allow_html
265
- ValidationMiddleware::DANGEROUS_PATTERNS.each do |pattern|
266
- if input_str.match?(pattern)
267
- raise Otto::Security::ValidationError, 'Dangerous content detected'
268
- end
269
- end
270
- end
271
-
272
- # Always check for SQL injection
273
- ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
274
- if input_str.match?(pattern)
275
- raise Otto::Security::ValidationError, 'Potential SQL injection detected'
276
- end
277
- end
278
-
279
- input_str
280
- end
281
-
282
- def sanitize_filename(filename)
283
- return nil if filename.nil? || filename.empty?
284
-
285
- # Remove path components and dangerous characters
286
- clean_name = File.basename(filename.to_s)
287
- clean_name = clean_name.gsub(/[^\w\-_\.]/, '_')
288
- clean_name = clean_name.gsub(/_{2,}/, '_')
289
- clean_name = clean_name.gsub(/^_+|_+$/, '')
290
-
291
- # Ensure it's not empty and has reasonable length
292
- clean_name = 'file' if clean_name.empty?
293
- clean_name = clean_name[0..100] if clean_name.length > 100
294
-
295
- clean_name
296
- end
297
- end
12
+ # Backward compatibility alias
13
+ ValidationMiddleware = Middleware::ValidationMiddleware
298
14
  end
299
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.5.0'.freeze
6
+ VERSION = '2.0.0.pre1'
5
7
  end