otto 1.1.0.pre.alpha4 → 1.2.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,181 @@
1
+ # lib/otto/security/csrf.rb
2
+
3
+ require 'securerandom'
4
+
5
+ class Otto
6
+ module Security
7
+ class CSRFMiddleware
8
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
9
+
10
+ def initialize(app, config = nil)
11
+ @app = app
12
+ @config = config || Otto::Security::Config.new
13
+ end
14
+
15
+ def call(env)
16
+ return @app.call(env) unless @config.csrf_enabled?
17
+
18
+ request = Rack::Request.new(env)
19
+
20
+ # Skip CSRF protection for safe methods
21
+ if safe_method?(request.request_method)
22
+ response = @app.call(env)
23
+ response = inject_csrf_token(request, response) if html_response?(response)
24
+ return response
25
+ end
26
+
27
+ # Validate CSRF token for unsafe methods
28
+ unless valid_csrf_token?(request)
29
+ return csrf_error_response
30
+ end
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_REQUESTED_WITH'] == 'XMLHttpRequest' ?
58
+ request.env['HTTP_X_CSRF_TOKEN'] : nil
59
+
60
+ token
61
+ end
62
+
63
+ def extract_session_id(request)
64
+ @config.get_or_create_session_id(request)
65
+ end
66
+
67
+ def inject_csrf_token(request, response)
68
+ return response unless response.is_a?(Array) && response.length >= 3
69
+
70
+ status, headers, body = response
71
+ content_type = headers.find { |k, v| k.downcase == 'content-type' }&.last
72
+
73
+ return response unless content_type&.include?('text/html')
74
+
75
+ # Get or create session ID
76
+ session_id = @config.get_or_create_session_id(request)
77
+
78
+ # Ensure session ID is saved to cookie if it was newly created
79
+ ensure_session_cookie(request, headers, session_id)
80
+
81
+ # Generate new CSRF token
82
+ csrf_token = @config.generate_csrf_token(session_id)
83
+
84
+ # Inject meta tag into HTML head
85
+ body_content = body.respond_to?(:join) ? body.join : body.to_s
86
+
87
+ if body_content.match?(/<head>/i)
88
+ meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
89
+ body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
90
+
91
+ # Update content length if present
92
+ content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
93
+ if content_length_key
94
+ headers[content_length_key] = body_content.bytesize.to_s
95
+ end
96
+
97
+ [status, headers, [body_content]]
98
+ else
99
+ response
100
+ end
101
+ end
102
+
103
+ def ensure_session_cookie(request, headers, session_id)
104
+ # Check if session ID already exists in cookies
105
+ existing_cookie = request.cookies['_otto_session']
106
+ return if existing_cookie == session_id
107
+
108
+ # Set the session cookie
109
+ cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
110
+ cookie_value += "; Secure" if request.scheme == 'https'
111
+
112
+ # Handle existing Set-Cookie headers
113
+ existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
114
+ if existing_cookies
115
+ # Append to existing cookies (handle both string and array formats)
116
+ if existing_cookies.is_a?(Array)
117
+ existing_cookies << "_otto_session=#{cookie_value}"
118
+ else
119
+ headers['set-cookie'] = [existing_cookies, "_otto_session=#{cookie_value}"]
120
+ end
121
+ else
122
+ headers['set-cookie'] = "_otto_session=#{cookie_value}"
123
+ end
124
+ end
125
+
126
+ def html_response?(response)
127
+ return false unless response.is_a?(Array) && response.length >= 2
128
+
129
+ headers = response[1]
130
+ content_type = headers.find { |k, v| k.downcase == 'content-type' }&.last
131
+ content_type&.include?('text/html')
132
+ end
133
+
134
+ def csrf_error_response
135
+ [
136
+ 403,
137
+ {
138
+ 'content-type' => 'application/json',
139
+ 'content-length' => csrf_error_body.bytesize.to_s
140
+ },
141
+ [csrf_error_body]
142
+ ]
143
+ end
144
+
145
+ def csrf_error_body
146
+ {
147
+ error: 'CSRF token validation failed',
148
+ message: 'The request could not be authenticated. Please refresh the page and try again.'
149
+ }.to_json
150
+ end
151
+
152
+ end
153
+
154
+ module CSRFHelpers
155
+ def csrf_token
156
+ if @csrf_token.nil? && otto.respond_to?(:security_config)
157
+ session_id = otto.security_config.get_or_create_session_id(req)
158
+ @csrf_token = otto.security_config.generate_csrf_token(session_id)
159
+ end
160
+ @csrf_token
161
+ end
162
+
163
+ private
164
+
165
+ public
166
+
167
+ def csrf_meta_tag
168
+ %(<meta name="csrf-token" content="#{csrf_token}">)
169
+ end
170
+
171
+ def csrf_form_tag
172
+ %(<input type="hidden" name="#{csrf_token_key}" value="#{csrf_token}">)
173
+ end
174
+
175
+ def csrf_token_key
176
+ otto.respond_to?(:security_config) ?
177
+ otto.security_config.csrf_token_key : '_csrf_token'
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,296 @@
1
+ # lib/otto/security/validator.rb
2
+
3
+ require 'json'
4
+ require 'cgi'
5
+
6
+ class Otto
7
+ module Security
8
+ class ValidationMiddleware
9
+ # Character validation patterns
10
+ INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n.freeze
11
+ NULL_BYTE = /\0/.freeze
12
+
13
+ DANGEROUS_PATTERNS = [
14
+ /<script[^>]*>/i, # Script tags
15
+ /javascript:/i, # JavaScript protocol
16
+ /data:.*base64/i, # Data URLs with base64
17
+ /on\w+\s*=/i, # Event handlers
18
+ /expression\s*\(/i, # CSS expressions
19
+ /url\s*\(/i, # CSS url() functions
20
+ NULL_BYTE, # Null bytes
21
+ INVALID_CHARACTERS # Control characters and extended ASCII
22
+ ].freeze
23
+
24
+ SQL_INJECTION_PATTERNS = [
25
+ /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
26
+ /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
27
+ /(or|and)\s+\w+\s*=\s*\w+/i,
28
+ /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i
29
+ ].freeze
30
+
31
+ def initialize(app, config = nil)
32
+ @app = app
33
+ @config = config || Otto::Security::Config.new
34
+ end
35
+
36
+ def call(env)
37
+ return @app.call(env) unless @config.input_validation
38
+
39
+ request = Rack::Request.new(env)
40
+
41
+ begin
42
+ # Validate request size
43
+ validate_request_size(request)
44
+
45
+ # Validate content type
46
+ validate_content_type(request)
47
+
48
+ # Validate and sanitize parameters
49
+ validate_parameters(request) if request.params
50
+
51
+ # Validate headers
52
+ validate_headers(request)
53
+
54
+ @app.call(env)
55
+
56
+ rescue Otto::Security::ValidationError => e
57
+ return validation_error_response(e.message)
58
+ rescue Otto::Security::RequestTooLargeError => e
59
+ return request_too_large_response(e.message)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def validate_request_size(request)
66
+ content_length = request.env['CONTENT_LENGTH']
67
+ @config.validate_request_size(content_length)
68
+ end
69
+
70
+ def validate_content_type(request)
71
+ content_type = request.env['CONTENT_TYPE']
72
+ return unless content_type
73
+
74
+ # Block dangerous content types
75
+ dangerous_types = [
76
+ 'application/x-shockwave-flash',
77
+ 'application/x-silverlight-app',
78
+ 'text/vbscript',
79
+ 'application/vbscript'
80
+ ]
81
+
82
+ if dangerous_types.any? { |type| content_type.downcase.include?(type) }
83
+ raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
84
+ end
85
+ end
86
+
87
+ def validate_parameters(request)
88
+ validate_param_structure(request.params, 0)
89
+ sanitize_params(request.params)
90
+ end
91
+
92
+ def validate_param_structure(params, depth = 0)
93
+ if depth > @config.max_param_depth
94
+ raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
95
+ end
96
+
97
+ case params
98
+ when Hash
99
+ if params.keys.length > @config.max_param_keys
100
+ raise Otto::Security::ValidationError, "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
101
+ end
102
+
103
+ params.each do |key, value|
104
+ validate_param_key(key)
105
+ validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
106
+ end
107
+ when Array
108
+ if params.length > @config.max_param_keys
109
+ raise Otto::Security::ValidationError, "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
+ if key_str.length > 256
128
+ raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
129
+ end
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 dangerous patterns
149
+ DANGEROUS_PATTERNS.each do |pattern|
150
+ if value.match?(pattern)
151
+ raise Otto::Security::ValidationError, "Dangerous content detected in parameter"
152
+ end
153
+ end
154
+
155
+ # Check for SQL injection patterns
156
+ SQL_INJECTION_PATTERNS.each do |pattern|
157
+ if value.match?(pattern)
158
+ raise Otto::Security::ValidationError, "Potential SQL injection detected"
159
+ end
160
+ end
161
+
162
+ # Check for extremely long values
163
+ if value.length > 10_000
164
+ raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
165
+ end
166
+
167
+ # Basic sanitization - remove null bytes and control characters
168
+ sanitized = value.gsub(/\0/, '').gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
169
+
170
+ # Additional sanitization for common attack vectors
171
+ sanitized = sanitized.gsub(/<!--.*?-->/m, '') # Remove HTML comments
172
+ sanitized = sanitized.gsub(/<!\[CDATA\[.*?\]\]>/m, '') # Remove CDATA sections
173
+
174
+ sanitized
175
+ end
176
+
177
+ def validate_headers(request)
178
+ # Check for dangerous headers
179
+ dangerous_headers = %w[
180
+ HTTP_X_FORWARDED_HOST
181
+ HTTP_X_ORIGINAL_URL
182
+ HTTP_X_REWRITE_URL
183
+ HTTP_DESTINATION
184
+ HTTP_UPGRADE_INSECURE_REQUESTS
185
+ ]
186
+
187
+ dangerous_headers.each do |header|
188
+ value = request.env[header]
189
+ next unless value
190
+
191
+ # Basic validation - no null bytes or control characters
192
+ if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
193
+ raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
194
+ end
195
+ end
196
+
197
+ # Validate User-Agent length
198
+ user_agent = request.env['HTTP_USER_AGENT']
199
+ if user_agent && user_agent.length > 1000
200
+ raise Otto::Security::ValidationError, "User-Agent header too long"
201
+ end
202
+
203
+ # Validate Referer header
204
+ referer = request.env['HTTP_REFERER']
205
+ if referer && referer.length > 2000
206
+ raise Otto::Security::ValidationError, "Referer header too long"
207
+ end
208
+ end
209
+
210
+ def validation_error_response(message)
211
+ [
212
+ 400,
213
+ {
214
+ 'content-type' => 'application/json',
215
+ 'content-length' => validation_error_body(message).bytesize.to_s
216
+ },
217
+ [validation_error_body(message)]
218
+ ]
219
+ end
220
+
221
+ def request_too_large_response(message)
222
+ [
223
+ 413,
224
+ {
225
+ 'content-type' => 'application/json',
226
+ 'content-length' => request_too_large_body(message).bytesize.to_s
227
+ },
228
+ [request_too_large_body(message)]
229
+ ]
230
+ end
231
+
232
+ def validation_error_body(message)
233
+ require 'json'
234
+ {
235
+ error: 'Validation failed',
236
+ message: message
237
+ }.to_json
238
+ end
239
+
240
+ def request_too_large_body(message)
241
+ require 'json'
242
+ {
243
+ error: 'Request too large',
244
+ message: message
245
+ }.to_json
246
+ end
247
+ end
248
+
249
+ module ValidationHelpers
250
+ def validate_input(input, max_length: 1000, allow_html: false)
251
+ return input if input.nil? || input.empty?
252
+
253
+ input_str = input.to_s
254
+
255
+ # Check length
256
+ if input_str.length > max_length
257
+ raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
258
+ end
259
+
260
+ # Check for dangerous patterns unless HTML is allowed
261
+ unless allow_html
262
+ ValidationMiddleware::DANGEROUS_PATTERNS.each do |pattern|
263
+ if input_str.match?(pattern)
264
+ raise Otto::Security::ValidationError, "Dangerous content detected"
265
+ end
266
+ end
267
+ end
268
+
269
+ # Always check for SQL injection
270
+ ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
271
+ if input_str.match?(pattern)
272
+ raise Otto::Security::ValidationError, "Potential SQL injection detected"
273
+ end
274
+ end
275
+
276
+ input_str
277
+ end
278
+
279
+ def sanitize_filename(filename)
280
+ return nil if filename.nil? || filename.empty?
281
+
282
+ # Remove path components and dangerous characters
283
+ clean_name = File.basename(filename.to_s)
284
+ clean_name = clean_name.gsub(/[^\w\-_\.]/, '_')
285
+ clean_name = clean_name.gsub(/_{2,}/, '_')
286
+ clean_name = clean_name.gsub(/^_+|_+$/, '')
287
+
288
+ # Ensure it's not empty and has reasonable length
289
+ clean_name = 'file' if clean_name.empty?
290
+ clean_name = clean_name[0..100] if clean_name.length > 100
291
+
292
+ clean_name
293
+ end
294
+ end
295
+ end
296
+ 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,4 +1,4 @@
1
- #
1
+ # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
4
  # Otto::VERSION
@@ -6,12 +6,13 @@ class Otto
6
6
  module VERSION
7
7
  def self.to_a
8
8
  load_config
9
- [@version[:MAJOR], @version[:MINOR], @version[:PATCH]]
9
+ version = [@version[:MAJOR], @version[:MINOR], @version[:PATCH]]
10
+ version << @version[:PRE] unless @version.fetch(:PRE, nil).to_s.empty?
11
+ version
10
12
  end
11
13
 
12
14
  def self.to_s
13
- version = to_a.join('.')
14
- "#{version}-#{@version[:PRE]}" if @version[:PRE]
15
+ to_a.join('.')
15
16
  end
16
17
 
17
18
  def self.inspect
@@ -20,6 +21,7 @@ class Otto
20
21
 
21
22
  def self.load_config
22
23
  return if @version
24
+
23
25
  require 'yaml'
24
26
  @version = YAML.load_file(File.join(__dir__, '..', '..', 'VERSION.yml'))
25
27
  end