otto 1.2.0 → 1.4.0
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/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +365 -21
- data/Gemfile +1 -3
- data/Gemfile.lock +78 -46
- data/README.md +58 -2
- data/examples/basic/app.rb +20 -20
- data/examples/basic/config.ru +1 -1
- data/examples/dynamic_pages/app.rb +30 -30
- data/examples/dynamic_pages/config.ru +1 -1
- data/examples/helpers_demo/app.rb +244 -0
- data/examples/helpers_demo/config.ru +26 -0
- data/examples/helpers_demo/routes +7 -0
- data/examples/security_features/app.rb +90 -87
- data/examples/security_features/config.ru +19 -17
- data/lib/otto/design_system.rb +22 -22
- data/lib/otto/helpers/base.rb +27 -0
- data/lib/otto/helpers/request.rb +226 -9
- data/lib/otto/helpers/response.rb +85 -38
- data/lib/otto/route.rb +17 -12
- data/lib/otto/security/config.rb +132 -38
- data/lib/otto/security/csrf.rb +23 -27
- data/lib/otto/security/validator.rb +33 -30
- data/lib/otto/static.rb +1 -1
- data/lib/otto/version.rb +1 -25
- data/lib/otto.rb +171 -61
- data/otto.gemspec +11 -12
- metadata +15 -15
- data/.rubocop_todo.yml +0 -152
- data/VERSION.yml +0 -5
data/lib/otto/route.rb
CHANGED
@@ -33,20 +33,20 @@ class Otto
|
|
33
33
|
# @param definition [String] Class and method definition (Class.method or Class#method)
|
34
34
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
35
35
|
def initialize(verb, path, definition)
|
36
|
-
@verb
|
37
|
-
@path
|
38
|
-
@definition
|
36
|
+
@verb = verb.to_s.upcase.to_sym
|
37
|
+
@path = path
|
38
|
+
@definition = definition
|
39
39
|
@pattern, @keys = *compile(@path)
|
40
40
|
if !@definition.index('.').nil?
|
41
41
|
@klass, @name = @definition.split('.')
|
42
|
-
@kind
|
42
|
+
@kind = :class
|
43
43
|
elsif !@definition.index('#').nil?
|
44
44
|
@klass, @name = @definition.split('#')
|
45
|
-
@kind
|
45
|
+
@kind = :instance
|
46
46
|
else
|
47
47
|
raise ArgumentError, "Bad definition: #{@definition}"
|
48
48
|
end
|
49
|
-
@klass
|
49
|
+
@klass = safe_const_get(@klass)
|
50
50
|
# @method = @klass.method(@name)
|
51
51
|
end
|
52
52
|
|
@@ -85,8 +85,8 @@ class Otto
|
|
85
85
|
|
86
86
|
begin
|
87
87
|
Object.const_get(class_name)
|
88
|
-
rescue NameError =>
|
89
|
-
raise ArgumentError, "Class not found: #{class_name} - #{
|
88
|
+
rescue NameError => ex
|
89
|
+
raise ArgumentError, "Class not found: #{class_name} - #{ex.message}"
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
@@ -109,11 +109,16 @@ class Otto
|
|
109
109
|
# @return [Array] Rack response array [status, headers, body]
|
110
110
|
def call(env, extra_params = {})
|
111
111
|
extra_params ||= {}
|
112
|
-
req
|
113
|
-
res
|
112
|
+
req = Rack::Request.new(env)
|
113
|
+
res = Rack::Response.new
|
114
114
|
req.extend Otto::RequestHelpers
|
115
115
|
res.extend Otto::ResponseHelpers
|
116
|
-
res.request
|
116
|
+
res.request = req
|
117
|
+
|
118
|
+
# Make security config available to response helpers
|
119
|
+
if otto.respond_to?(:security_config) && otto.security_config
|
120
|
+
env['otto.security_config'] = otto.security_config
|
121
|
+
end
|
117
122
|
|
118
123
|
# Process parameters through security layer
|
119
124
|
req.params.merge! extra_params
|
@@ -158,7 +163,7 @@ class Otto
|
|
158
163
|
keys = []
|
159
164
|
if path.respond_to? :to_str
|
160
165
|
special_chars = %w[. + ( ) $]
|
161
|
-
pattern
|
166
|
+
pattern =
|
162
167
|
path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
|
163
168
|
case match
|
164
169
|
when '*'
|
data/lib/otto/security/config.rb
CHANGED
@@ -22,26 +22,29 @@ class Otto
|
|
22
22
|
# config.max_param_depth = 16
|
23
23
|
class Config
|
24
24
|
attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
|
25
|
-
|
26
|
-
|
27
|
-
|
25
|
+
:max_request_size, :max_param_depth, :max_param_keys,
|
26
|
+
:trusted_proxies, :require_secure_cookies,
|
27
|
+
:security_headers, :input_validation,
|
28
|
+
:csp_nonce_enabled, :debug_csp
|
28
29
|
|
29
30
|
# Initialize security configuration with safe defaults
|
30
31
|
#
|
31
32
|
# All security features are disabled by default to maintain backward
|
32
33
|
# compatibility with existing Otto applications.
|
33
34
|
def initialize
|
34
|
-
@csrf_protection
|
35
|
-
@csrf_token_key
|
36
|
-
@csrf_header_key
|
37
|
-
@csrf_session_key
|
38
|
-
@max_request_size
|
39
|
-
@max_param_depth
|
40
|
-
@max_param_keys
|
41
|
-
@trusted_proxies
|
35
|
+
@csrf_protection = false
|
36
|
+
@csrf_token_key = '_csrf_token'
|
37
|
+
@csrf_header_key = 'HTTP_X_CSRF_TOKEN'
|
38
|
+
@csrf_session_key = '_csrf_session_id'
|
39
|
+
@max_request_size = 10 * 1024 * 1024 # 10MB
|
40
|
+
@max_param_depth = 32
|
41
|
+
@max_param_keys = 64
|
42
|
+
@trusted_proxies = []
|
42
43
|
@require_secure_cookies = false
|
43
|
-
@security_headers
|
44
|
-
@input_validation
|
44
|
+
@security_headers = default_security_headers
|
45
|
+
@input_validation = true
|
46
|
+
@csp_nonce_enabled = false
|
47
|
+
@debug_csp = false
|
45
48
|
end
|
46
49
|
|
47
50
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
@@ -96,7 +99,7 @@ class Otto
|
|
96
99
|
when Array
|
97
100
|
@trusted_proxies.concat(proxy)
|
98
101
|
else
|
99
|
-
raise ArgumentError,
|
102
|
+
raise ArgumentError, 'Proxy must be a String or Array'
|
100
103
|
end
|
101
104
|
end
|
102
105
|
|
@@ -130,19 +133,19 @@ class Otto
|
|
130
133
|
size = content_length.to_i
|
131
134
|
if size > @max_request_size
|
132
135
|
raise Otto::Security::RequestTooLargeError,
|
133
|
-
|
136
|
+
"Request size #{size} exceeds maximum #{@max_request_size}"
|
134
137
|
end
|
135
138
|
true
|
136
139
|
end
|
137
140
|
|
138
141
|
def generate_csrf_token(session_id = nil)
|
139
|
-
base
|
140
|
-
token
|
142
|
+
base = session_id || 'no-session'
|
143
|
+
token = SecureRandom.hex(32)
|
141
144
|
hash_input = base + ':' + token
|
142
|
-
signature
|
145
|
+
signature = Digest::SHA256.hexdigest(hash_input)
|
143
146
|
csrf_token = "#{token}:#{signature}"
|
144
147
|
|
145
|
-
puts
|
148
|
+
puts '=== CSRF Generation ==='
|
146
149
|
puts "hash_input: #{hash_input.inspect}"
|
147
150
|
puts "signature: #{signature}"
|
148
151
|
puts "csrf_token: #{csrf_token}"
|
@@ -156,12 +159,12 @@ class Otto
|
|
156
159
|
token_part, signature = token.split(':')
|
157
160
|
return false if token_part.nil? || signature.nil?
|
158
161
|
|
159
|
-
base
|
160
|
-
hash_input
|
162
|
+
base = session_id || 'no-session'
|
163
|
+
hash_input = "#{base}:#{token_part}"
|
161
164
|
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
162
|
-
comparison_result
|
165
|
+
comparison_result = secure_compare(signature, expected_signature)
|
163
166
|
|
164
|
-
puts
|
167
|
+
puts '=== CSRF Verification ==='
|
165
168
|
puts "hash_input: #{hash_input.inspect}"
|
166
169
|
puts "received_signature: #{signature}"
|
167
170
|
puts "expected_signature: #{expected_signature}"
|
@@ -179,9 +182,9 @@ class Otto
|
|
179
182
|
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
180
183
|
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
181
184
|
# @return [void]
|
182
|
-
def enable_hsts!(max_age:
|
183
|
-
hsts_value
|
184
|
-
hsts_value
|
185
|
+
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
186
|
+
hsts_value = "max-age=#{max_age}"
|
187
|
+
hsts_value += '; includeSubDomains' if include_subdomains
|
185
188
|
@security_headers['strict-transport-security'] = hsts_value
|
186
189
|
end
|
187
190
|
|
@@ -199,6 +202,53 @@ class Otto
|
|
199
202
|
@security_headers['content-security-policy'] = policy
|
200
203
|
end
|
201
204
|
|
205
|
+
# Enable Content Security Policy (CSP) with nonce support
|
206
|
+
#
|
207
|
+
# This enables dynamic CSP header generation with nonces for enhanced security.
|
208
|
+
# Unlike enable_csp!, this doesn't set a static policy but enables the response
|
209
|
+
# helper to generate CSP headers with nonces on a per-request basis.
|
210
|
+
#
|
211
|
+
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
212
|
+
# @return [void]
|
213
|
+
#
|
214
|
+
# @example
|
215
|
+
# config.enable_csp_with_nonce!(debug: true)
|
216
|
+
def enable_csp_with_nonce!(debug: false)
|
217
|
+
@csp_nonce_enabled = true
|
218
|
+
@debug_csp = debug
|
219
|
+
end
|
220
|
+
|
221
|
+
# Disable CSP nonce support
|
222
|
+
#
|
223
|
+
# @return [void]
|
224
|
+
def disable_csp_nonce!
|
225
|
+
@csp_nonce_enabled = false
|
226
|
+
end
|
227
|
+
|
228
|
+
# Check if CSP nonce support is enabled
|
229
|
+
#
|
230
|
+
# @return [Boolean] true if CSP nonce support is enabled
|
231
|
+
def csp_nonce_enabled?
|
232
|
+
@csp_nonce_enabled
|
233
|
+
end
|
234
|
+
|
235
|
+
# Check if CSP debug logging is enabled
|
236
|
+
#
|
237
|
+
# @return [Boolean] true if CSP debug logging is enabled
|
238
|
+
def debug_csp?
|
239
|
+
@debug_csp
|
240
|
+
end
|
241
|
+
|
242
|
+
# Generate a CSP policy string with the provided nonce
|
243
|
+
#
|
244
|
+
# @param nonce [String] The nonce value to include in the CSP
|
245
|
+
# @param development_mode [Boolean] Whether to use development-friendly directives
|
246
|
+
# @return [String] Complete CSP policy string
|
247
|
+
def generate_nonce_csp(nonce, development_mode: false)
|
248
|
+
directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
|
249
|
+
directives.join(' ')
|
250
|
+
end
|
251
|
+
|
202
252
|
# Enable X-Frame-Options header to prevent clickjacking
|
203
253
|
#
|
204
254
|
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
@@ -245,27 +295,23 @@ class Otto
|
|
245
295
|
return session[csrf_session_key] if session[csrf_session_key]
|
246
296
|
return session['session_id'] if session['session_id']
|
247
297
|
end
|
248
|
-
rescue
|
298
|
+
rescue StandardError
|
249
299
|
# Fall through to cookies
|
250
300
|
end
|
251
301
|
|
252
302
|
# Try cookies
|
253
303
|
request.cookies['_otto_session'] ||
|
254
|
-
|
255
|
-
|
304
|
+
request.cookies['session_id'] ||
|
305
|
+
request.cookies['_session_id']
|
256
306
|
end
|
257
307
|
|
258
308
|
def store_session_id(request, session_id)
|
259
|
-
|
260
|
-
session = request.session
|
309
|
+
session = request.session
|
261
310
|
session[csrf_session_key] = session_id if session
|
262
|
-
|
263
|
-
|
264
|
-
end
|
311
|
+
rescue StandardError
|
312
|
+
# Cookie fallback handled in inject_csrf_token
|
265
313
|
end
|
266
314
|
|
267
|
-
private
|
268
|
-
|
269
315
|
# Default security headers applied to all responses
|
270
316
|
#
|
271
317
|
# These headers provide basic defense against common web vulnerabilities:
|
@@ -282,7 +328,7 @@ class Otto
|
|
282
328
|
{
|
283
329
|
'x-content-type-options' => 'nosniff',
|
284
330
|
'x-xss-protection' => '1; mode=block',
|
285
|
-
'referrer-policy' => 'strict-origin-when-cross-origin'
|
331
|
+
'referrer-policy' => 'strict-origin-when-cross-origin',
|
286
332
|
}
|
287
333
|
end
|
288
334
|
|
@@ -298,10 +344,58 @@ class Otto
|
|
298
344
|
def secure_compare(a, b)
|
299
345
|
return false if a.nil? || b.nil? || a.length != b.length
|
300
346
|
|
301
|
-
result
|
347
|
+
result = 0
|
302
348
|
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
303
349
|
result == 0
|
304
350
|
end
|
351
|
+
|
352
|
+
# Generate CSP directives for development environment
|
353
|
+
#
|
354
|
+
# Development mode allows inline scripts/styles and hot reloading connections
|
355
|
+
# for better developer experience with build tools like Vite.
|
356
|
+
#
|
357
|
+
# @param nonce [String] The nonce value to include in script-src
|
358
|
+
# @return [Array<String>] Array of CSP directive strings
|
359
|
+
def development_csp_directives(nonce)
|
360
|
+
[
|
361
|
+
"default-src 'none';",
|
362
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
363
|
+
"style-src 'self' 'unsafe-inline';",
|
364
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
365
|
+
"img-src 'self' data:;",
|
366
|
+
"font-src 'self';",
|
367
|
+
"object-src 'none';",
|
368
|
+
"base-uri 'self';",
|
369
|
+
"form-action 'self';",
|
370
|
+
"frame-ancestors 'none';",
|
371
|
+
"manifest-src 'self';",
|
372
|
+
"worker-src 'self' data:;",
|
373
|
+
]
|
374
|
+
end
|
375
|
+
|
376
|
+
# Generate CSP directives for production environment
|
377
|
+
#
|
378
|
+
# Production mode is more restrictive, only allowing HTTPS connections
|
379
|
+
# and nonce-only scripts for enhanced XSS protection.
|
380
|
+
#
|
381
|
+
# @param nonce [String] The nonce value to include in script-src
|
382
|
+
# @return [Array<String>] Array of CSP directive strings
|
383
|
+
def production_csp_directives(nonce)
|
384
|
+
[
|
385
|
+
"default-src 'none';", # Restrict to same origin by default
|
386
|
+
"script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
|
387
|
+
"style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
|
388
|
+
"connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
|
389
|
+
"img-src 'self' data:;", # Allow images from same origin and data URIs
|
390
|
+
"font-src 'self';", # Allow fonts from same origin only
|
391
|
+
"object-src 'none';", # Block <object>, <embed>, and <applet> elements
|
392
|
+
"base-uri 'self';", # Restrict <base> tag targets to same origin
|
393
|
+
"form-action 'self';", # Restrict form submissions to same origin
|
394
|
+
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
395
|
+
"manifest-src 'self';", # Allow web app manifests from same origin
|
396
|
+
"worker-src 'self' data:;", # Allow Workers from same origin and data blobs
|
397
|
+
]
|
398
|
+
end
|
305
399
|
end
|
306
400
|
|
307
401
|
# Raised when a request exceeds the configured size limit
|
data/lib/otto/security/csrf.rb
CHANGED
@@ -3,12 +3,14 @@
|
|
3
3
|
require 'securerandom'
|
4
4
|
|
5
5
|
class Otto
|
6
|
+
# CSRF protection middleware for Otto framework
|
6
7
|
module Security
|
8
|
+
# Middleware that provides Cross-Site Request Forgery (CSRF) protection
|
7
9
|
class CSRFMiddleware
|
8
10
|
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
9
11
|
|
10
12
|
def initialize(app, config = nil)
|
11
|
-
@app
|
13
|
+
@app = app
|
12
14
|
@config = config || Otto::Security::Config.new
|
13
15
|
end
|
14
16
|
|
@@ -25,9 +27,7 @@ class Otto
|
|
25
27
|
end
|
26
28
|
|
27
29
|
# Validate CSRF token for unsafe methods
|
28
|
-
unless valid_csrf_token?(request)
|
29
|
-
return csrf_error_response
|
30
|
-
end
|
30
|
+
return csrf_error_response unless valid_csrf_token?(request)
|
31
31
|
|
32
32
|
@app.call(env)
|
33
33
|
end
|
@@ -54,8 +54,7 @@ class Otto
|
|
54
54
|
token ||= request.env[@config.csrf_header_key]
|
55
55
|
|
56
56
|
# Try alternative header format
|
57
|
-
token ||= request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
58
|
-
request.env['HTTP_X_CSRF_TOKEN'] : nil
|
57
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
59
58
|
|
60
59
|
token
|
61
60
|
end
|
@@ -68,7 +67,7 @@ class Otto
|
|
68
67
|
return response unless response.is_a?(Array) && response.length >= 3
|
69
68
|
|
70
69
|
status, headers, body = response
|
71
|
-
content_type
|
70
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
72
71
|
|
73
72
|
return response unless content_type&.include?('text/html')
|
74
73
|
|
@@ -85,14 +84,12 @@ class Otto
|
|
85
84
|
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
86
85
|
|
87
86
|
if body_content.match?(/<head>/i)
|
88
|
-
meta_tag
|
87
|
+
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
89
88
|
body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
|
90
89
|
|
91
90
|
# Update content length if present
|
92
|
-
content_length_key
|
93
|
-
if content_length_key
|
94
|
-
headers[content_length_key] = body_content.bytesize.to_s
|
95
|
-
end
|
91
|
+
content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
|
92
|
+
headers[content_length_key] = body_content.bytesize.to_s if content_length_key
|
96
93
|
|
97
94
|
[status, headers, [body_content]]
|
98
95
|
else
|
@@ -106,8 +103,8 @@ class Otto
|
|
106
103
|
return if existing_cookie == session_id
|
107
104
|
|
108
105
|
# Set the session cookie
|
109
|
-
cookie_value
|
110
|
-
cookie_value +=
|
106
|
+
cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
107
|
+
cookie_value += '; Secure' if request.scheme == 'https'
|
111
108
|
|
112
109
|
# Handle existing Set-Cookie headers
|
113
110
|
existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
|
@@ -126,8 +123,8 @@ class Otto
|
|
126
123
|
def html_response?(response)
|
127
124
|
return false unless response.is_a?(Array) && response.length >= 2
|
128
125
|
|
129
|
-
headers
|
130
|
-
content_type = headers.find { |k,
|
126
|
+
headers = response[1]
|
127
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
131
128
|
content_type&.include?('text/html')
|
132
129
|
end
|
133
130
|
|
@@ -136,34 +133,30 @@ class Otto
|
|
136
133
|
403,
|
137
134
|
{
|
138
135
|
'content-type' => 'application/json',
|
139
|
-
'content-length' => csrf_error_body.bytesize.to_s
|
136
|
+
'content-length' => csrf_error_body.bytesize.to_s,
|
140
137
|
},
|
141
|
-
[csrf_error_body]
|
138
|
+
[csrf_error_body],
|
142
139
|
]
|
143
140
|
end
|
144
141
|
|
145
142
|
def csrf_error_body
|
146
143
|
{
|
147
144
|
error: 'CSRF token validation failed',
|
148
|
-
message: 'The request could not be authenticated. Please refresh the page and try again.'
|
145
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
149
146
|
}.to_json
|
150
147
|
end
|
151
|
-
|
152
148
|
end
|
153
149
|
|
150
|
+
# Helper methods for CSRF token handling in views and controllers
|
154
151
|
module CSRFHelpers
|
155
152
|
def csrf_token
|
156
153
|
if @csrf_token.nil? && otto.respond_to?(:security_config)
|
157
|
-
session_id
|
154
|
+
session_id = otto.security_config.get_or_create_session_id(req)
|
158
155
|
@csrf_token = otto.security_config.generate_csrf_token(session_id)
|
159
156
|
end
|
160
157
|
@csrf_token
|
161
158
|
end
|
162
159
|
|
163
|
-
private
|
164
|
-
|
165
|
-
public
|
166
|
-
|
167
160
|
def csrf_meta_tag
|
168
161
|
%(<meta name="csrf-token" content="#{csrf_token}">)
|
169
162
|
end
|
@@ -173,8 +166,11 @@ class Otto
|
|
173
166
|
end
|
174
167
|
|
175
168
|
def csrf_token_key
|
176
|
-
otto.respond_to?(:security_config)
|
177
|
-
otto.security_config.csrf_token_key
|
169
|
+
if otto.respond_to?(:security_config)
|
170
|
+
otto.security_config.csrf_token_key
|
171
|
+
else
|
172
|
+
'_csrf_token'
|
173
|
+
end
|
178
174
|
end
|
179
175
|
end
|
180
176
|
end
|
@@ -5,31 +5,32 @@ require 'cgi'
|
|
5
5
|
|
6
6
|
class Otto
|
7
7
|
module Security
|
8
|
+
# ValidationMiddleware provides input validation and sanitization for web requests
|
8
9
|
class ValidationMiddleware
|
9
10
|
# Character validation patterns
|
10
|
-
INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
|
11
|
-
NULL_BYTE
|
11
|
+
INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
|
12
|
+
NULL_BYTE = /\0/
|
12
13
|
|
13
14
|
DANGEROUS_PATTERNS = [
|
14
15
|
/<script[^>]*>/i, # Script tags
|
15
16
|
/javascript:/i, # JavaScript protocol
|
16
17
|
/data:.*base64/i, # Data URLs with base64
|
17
|
-
/on\w+\s*=/i,
|
18
|
+
/on\w+\s*=/i, # Event handlers
|
18
19
|
/expression\s*\(/i, # CSS expressions
|
19
|
-
/url\s*\(/i,
|
20
|
-
NULL_BYTE,
|
21
|
-
INVALID_CHARACTERS # Control characters and extended ASCII
|
20
|
+
/url\s*\(/i, # CSS url() functions
|
21
|
+
NULL_BYTE, # Null bytes
|
22
|
+
INVALID_CHARACTERS, # Control characters and extended ASCII
|
22
23
|
].freeze
|
23
24
|
|
24
25
|
SQL_INJECTION_PATTERNS = [
|
25
26
|
/('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
|
26
27
|
/(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
|
27
28
|
/(or|and)\s+\w+\s*=\s*\w+/i,
|
28
|
-
/\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i
|
29
|
+
/\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
|
29
30
|
].freeze
|
30
31
|
|
31
32
|
def initialize(app, config = nil)
|
32
|
-
@app
|
33
|
+
@app = app
|
33
34
|
@config = config || Otto::Security::Config.new
|
34
35
|
end
|
35
36
|
|
@@ -46,17 +47,21 @@ class Otto
|
|
46
47
|
validate_content_type(request)
|
47
48
|
|
48
49
|
# Validate and sanitize parameters
|
49
|
-
|
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
|
50
56
|
|
51
57
|
# Validate headers
|
52
58
|
validate_headers(request)
|
53
59
|
|
54
60
|
@app.call(env)
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
return request_too_large_response(e.message)
|
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)
|
60
65
|
end
|
61
66
|
end
|
62
67
|
|
@@ -76,7 +81,7 @@ class Otto
|
|
76
81
|
'application/x-shockwave-flash',
|
77
82
|
'application/x-silverlight-app',
|
78
83
|
'text/vbscript',
|
79
|
-
'application/vbscript'
|
84
|
+
'application/vbscript',
|
80
85
|
]
|
81
86
|
|
82
87
|
if dangerous_types.any? { |type| content_type.downcase.include?(type) }
|
@@ -148,14 +153,14 @@ class Otto
|
|
148
153
|
# Check for dangerous patterns
|
149
154
|
DANGEROUS_PATTERNS.each do |pattern|
|
150
155
|
if value.match?(pattern)
|
151
|
-
raise Otto::Security::ValidationError,
|
156
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
|
152
157
|
end
|
153
158
|
end
|
154
159
|
|
155
160
|
# Check for SQL injection patterns
|
156
161
|
SQL_INJECTION_PATTERNS.each do |pattern|
|
157
162
|
if value.match?(pattern)
|
158
|
-
raise Otto::Security::ValidationError,
|
163
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
159
164
|
end
|
160
165
|
end
|
161
166
|
|
@@ -169,9 +174,7 @@ class Otto
|
|
169
174
|
|
170
175
|
# Additional sanitization for common attack vectors
|
171
176
|
sanitized = sanitized.gsub(/<!--.*?-->/m, '') # Remove HTML comments
|
172
|
-
sanitized
|
173
|
-
|
174
|
-
sanitized
|
177
|
+
sanitized.gsub(/<!\[CDATA\[.*?\]\]>/m, '') # Remove CDATA sections
|
175
178
|
end
|
176
179
|
|
177
180
|
def validate_headers(request)
|
@@ -197,13 +200,13 @@ class Otto
|
|
197
200
|
# Validate User-Agent length
|
198
201
|
user_agent = request.env['HTTP_USER_AGENT']
|
199
202
|
if user_agent && user_agent.length > 1000
|
200
|
-
raise Otto::Security::ValidationError,
|
203
|
+
raise Otto::Security::ValidationError, 'User-Agent header too long'
|
201
204
|
end
|
202
205
|
|
203
206
|
# Validate Referer header
|
204
207
|
referer = request.env['HTTP_REFERER']
|
205
208
|
if referer && referer.length > 2000
|
206
|
-
raise Otto::Security::ValidationError,
|
209
|
+
raise Otto::Security::ValidationError, 'Referer header too long'
|
207
210
|
end
|
208
211
|
end
|
209
212
|
|
@@ -212,9 +215,9 @@ class Otto
|
|
212
215
|
400,
|
213
216
|
{
|
214
217
|
'content-type' => 'application/json',
|
215
|
-
'content-length' => validation_error_body(message).bytesize.to_s
|
218
|
+
'content-length' => validation_error_body(message).bytesize.to_s,
|
216
219
|
},
|
217
|
-
[validation_error_body(message)]
|
220
|
+
[validation_error_body(message)],
|
218
221
|
]
|
219
222
|
end
|
220
223
|
|
@@ -223,9 +226,9 @@ class Otto
|
|
223
226
|
413,
|
224
227
|
{
|
225
228
|
'content-type' => 'application/json',
|
226
|
-
'content-length' => request_too_large_body(message).bytesize.to_s
|
229
|
+
'content-length' => request_too_large_body(message).bytesize.to_s,
|
227
230
|
},
|
228
|
-
[request_too_large_body(message)]
|
231
|
+
[request_too_large_body(message)],
|
229
232
|
]
|
230
233
|
end
|
231
234
|
|
@@ -233,7 +236,7 @@ class Otto
|
|
233
236
|
require 'json'
|
234
237
|
{
|
235
238
|
error: 'Validation failed',
|
236
|
-
message: message
|
239
|
+
message: message,
|
237
240
|
}.to_json
|
238
241
|
end
|
239
242
|
|
@@ -241,7 +244,7 @@ class Otto
|
|
241
244
|
require 'json'
|
242
245
|
{
|
243
246
|
error: 'Request too large',
|
244
|
-
message: message
|
247
|
+
message: message,
|
245
248
|
}.to_json
|
246
249
|
end
|
247
250
|
end
|
@@ -261,7 +264,7 @@ class Otto
|
|
261
264
|
unless allow_html
|
262
265
|
ValidationMiddleware::DANGEROUS_PATTERNS.each do |pattern|
|
263
266
|
if input_str.match?(pattern)
|
264
|
-
raise Otto::Security::ValidationError,
|
267
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected'
|
265
268
|
end
|
266
269
|
end
|
267
270
|
end
|
@@ -269,7 +272,7 @@ class Otto
|
|
269
272
|
# Always check for SQL injection
|
270
273
|
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
271
274
|
if input_str.match?(pattern)
|
272
|
-
raise Otto::Security::ValidationError,
|
275
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
273
276
|
end
|
274
277
|
end
|
275
278
|
|
data/lib/otto/static.rb
CHANGED