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,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
|
-
|
4
|
-
require 'cgi'
|
8
|
+
require_relative 'middleware/validation_middleware'
|
5
9
|
|
6
10
|
class Otto
|
7
11
|
module Security
|
8
|
-
#
|
9
|
-
|
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
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
|