otto 1.1.0.pre.alpha4 → 1.3.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.
@@ -0,0 +1,177 @@
1
+ # lib/otto/security/csrf.rb
2
+
3
+ require 'securerandom'
4
+
5
+ class Otto
6
+ # CSRF protection middleware for Otto framework
7
+ module Security
8
+ # Middleware that provides Cross-Site Request Forgery (CSRF) protection
9
+ class CSRFMiddleware
10
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
11
+
12
+ def initialize(app, config = nil)
13
+ @app = app
14
+ @config = config || Otto::Security::Config.new
15
+ end
16
+
17
+ def call(env)
18
+ return @app.call(env) unless @config.csrf_enabled?
19
+
20
+ request = Rack::Request.new(env)
21
+
22
+ # Skip CSRF protection for safe methods
23
+ if safe_method?(request.request_method)
24
+ response = @app.call(env)
25
+ response = inject_csrf_token(request, response) if html_response?(response)
26
+ return response
27
+ end
28
+
29
+ # Validate CSRF token for unsafe methods
30
+ return csrf_error_response unless valid_csrf_token?(request)
31
+
32
+ @app.call(env)
33
+ end
34
+
35
+ private
36
+
37
+ def safe_method?(method)
38
+ SAFE_METHODS.include?(method.upcase)
39
+ end
40
+
41
+ def valid_csrf_token?(request)
42
+ token = extract_csrf_token(request)
43
+ return false if token.nil? || token.empty?
44
+
45
+ session_id = @config.get_or_create_session_id(request)
46
+ @config.verify_csrf_token(token, session_id)
47
+ end
48
+
49
+ def extract_csrf_token(request)
50
+ # Try form parameter first
51
+ token = request.params[@config.csrf_token_key]
52
+
53
+ # Try header if not in params
54
+ token ||= request.env[@config.csrf_header_key]
55
+
56
+ # Try alternative header format
57
+ token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
58
+
59
+ token
60
+ end
61
+
62
+ def extract_session_id(request)
63
+ @config.get_or_create_session_id(request)
64
+ end
65
+
66
+ def inject_csrf_token(request, response)
67
+ return response unless response.is_a?(Array) && response.length >= 3
68
+
69
+ status, headers, body = response
70
+ content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
71
+
72
+ return response unless content_type&.include?('text/html')
73
+
74
+ # Get or create session ID
75
+ session_id = @config.get_or_create_session_id(request)
76
+
77
+ # Ensure session ID is saved to cookie if it was newly created
78
+ ensure_session_cookie(request, headers, session_id)
79
+
80
+ # Generate new CSRF token
81
+ csrf_token = @config.generate_csrf_token(session_id)
82
+
83
+ # Inject meta tag into HTML head
84
+ body_content = body.respond_to?(:join) ? body.join : body.to_s
85
+
86
+ if body_content.match?(/<head>/i)
87
+ meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
88
+ body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
89
+
90
+ # Update content length if present
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
93
+
94
+ [status, headers, [body_content]]
95
+ else
96
+ response
97
+ end
98
+ end
99
+
100
+ def ensure_session_cookie(request, headers, session_id)
101
+ # Check if session ID already exists in cookies
102
+ existing_cookie = request.cookies['_otto_session']
103
+ return if existing_cookie == session_id
104
+
105
+ # Set the session cookie
106
+ cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
107
+ cookie_value += '; Secure' if request.scheme == 'https'
108
+
109
+ # Handle existing Set-Cookie headers
110
+ existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
111
+ if existing_cookies
112
+ # Append to existing cookies (handle both string and array formats)
113
+ if existing_cookies.is_a?(Array)
114
+ existing_cookies << "_otto_session=#{cookie_value}"
115
+ else
116
+ headers['set-cookie'] = [existing_cookies, "_otto_session=#{cookie_value}"]
117
+ end
118
+ else
119
+ headers['set-cookie'] = "_otto_session=#{cookie_value}"
120
+ end
121
+ end
122
+
123
+ def html_response?(response)
124
+ return false unless response.is_a?(Array) && response.length >= 2
125
+
126
+ headers = response[1]
127
+ content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
128
+ content_type&.include?('text/html')
129
+ end
130
+
131
+ def csrf_error_response
132
+ [
133
+ 403,
134
+ {
135
+ 'content-type' => 'application/json',
136
+ 'content-length' => csrf_error_body.bytesize.to_s,
137
+ },
138
+ [csrf_error_body],
139
+ ]
140
+ end
141
+
142
+ def csrf_error_body
143
+ {
144
+ error: 'CSRF token validation failed',
145
+ message: 'The request could not be authenticated. Please refresh the page and try again.',
146
+ }.to_json
147
+ end
148
+ end
149
+
150
+ # Helper methods for CSRF token handling in views and controllers
151
+ module CSRFHelpers
152
+ def csrf_token
153
+ if @csrf_token.nil? && otto.respond_to?(:security_config)
154
+ session_id = otto.security_config.get_or_create_session_id(req)
155
+ @csrf_token = otto.security_config.generate_csrf_token(session_id)
156
+ end
157
+ @csrf_token
158
+ end
159
+
160
+ def csrf_meta_tag
161
+ %(<meta name="csrf-token" content="#{csrf_token}">)
162
+ end
163
+
164
+ def csrf_form_tag
165
+ %(<input type="hidden" name="#{csrf_token_key}" value="#{csrf_token}">)
166
+ end
167
+
168
+ def csrf_token_key
169
+ if otto.respond_to?(:security_config)
170
+ otto.security_config.csrf_token_key
171
+ else
172
+ '_csrf_token'
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,299 @@
1
+ # lib/otto/security/validator.rb
2
+
3
+ require 'json'
4
+ require 'cgi'
5
+
6
+ class Otto
7
+ 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
298
+ end
299
+ end
data/lib/otto/static.rb CHANGED
@@ -1,20 +1,33 @@
1
+ # lib/otto/static.rb
1
2
 
2
3
  class Otto
3
-
4
4
  module Static
5
5
  extend self
6
+
6
7
  def server_error
7
- [500, {'Content-Type'=>'text/plain'}, ["Server error"]]
8
+ [500, security_headers.merge({ 'content-type' => 'text/plain' }), ['Server error']]
8
9
  end
10
+
9
11
  def not_found
10
- [404, {'Content-Type'=>'text/plain'}, ["Not Found"]]
12
+ [404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
13
+ end
14
+
15
+ def security_headers
16
+ {
17
+ 'x-frame-options' => 'DENY',
18
+ 'x-content-type-options' => 'nosniff',
19
+ 'x-xss-protection' => '1; mode=block',
20
+ 'referrer-policy' => 'strict-origin-when-cross-origin',
21
+ }
11
22
  end
23
+
12
24
  # Enable string or symbol key access to the nested params hash.
13
25
  def indifferent_params(params)
14
26
  if params.is_a?(Hash)
15
27
  params = indifferent_hash.merge(params)
16
28
  params.each do |key, value|
17
29
  next unless value.is_a?(Hash) || value.is_a?(Array)
30
+
18
31
  params[key] = indifferent_params(value)
19
32
  end
20
33
  elsif params.is_a?(Array)
@@ -27,10 +40,10 @@ class Otto
27
40
  end
28
41
  end
29
42
  end
43
+
30
44
  # Creates a Hash with indifferent access.
31
45
  def indifferent_hash
32
- Hash.new {|hash,key| hash[key.to_s] if Symbol === key }
46
+ Hash.new { |hash, key| hash[key.to_s] if key.is_a?(Symbol) }
33
47
  end
34
48
  end
35
-
36
49
  end
data/lib/otto/version.rb CHANGED
@@ -1,27 +1,5 @@
1
- #
1
+ # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
- # Otto::VERSION
5
- #
6
- module VERSION
7
- def self.to_a
8
- load_config
9
- [@version[:MAJOR], @version[:MINOR], @version[:PATCH]]
10
- end
11
-
12
- def self.to_s
13
- version = to_a.join('.')
14
- "#{version}-#{@version[:PRE]}" if @version[:PRE]
15
- end
16
-
17
- def self.inspect
18
- to_s
19
- end
20
-
21
- def self.load_config
22
- return if @version
23
- require 'yaml'
24
- @version = YAML.load_file(File.join(__dir__, '..', '..', 'VERSION.yml'))
25
- end
26
- end
4
+ VERSION = '1.3.0'.freeze
27
5
  end