otto 1.1.0.pre.alpha3 → 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.
@@ -1,22 +1,108 @@
1
+ # lib/otto/helpers/response.rb
2
+
1
3
  class Otto
2
4
  module ResponseHelpers
3
5
  attr_accessor :request
4
- def send_secure_cookie name, value, ttl
5
- send_cookie name, value, ttl, true
6
+
7
+ def send_secure_cookie(name, value, ttl, opts = {})
8
+ send_cookie name, value, ttl, opts.merge(secure: true)
6
9
  end
7
- def send_cookie name, value, ttl, secure=true
8
- secure = false if request.local?
9
- opts = {
10
- :value => value,
11
- :path => '/',
12
- :expires => (Time.now.utc + ttl + 10),
13
- :secure => secure
10
+
11
+ def send_cookie(name, value, ttl, opts = {})
12
+ # Default security options
13
+ defaults = {
14
+ secure: true,
15
+ httponly: true,
16
+ samesite: :lax,
17
+ path: '/'
14
18
  }
15
- #opts[:domain] = request.env['SERVER_NAME']
16
- set_cookie name, opts
19
+
20
+ # Merge with provided options
21
+ cookie_opts = defaults.merge(opts)
22
+
23
+ # Adjust secure flag for local development
24
+ if request.local?
25
+ cookie_opts[:secure] = false
26
+ end
27
+
28
+ # Set expiration using max-age (preferred) and expires (fallback)
29
+ if ttl && ttl > 0
30
+ cookie_opts[:max_age] = ttl
31
+ cookie_opts[:expires] = (Time.now.utc + ttl + 10)
32
+ elsif ttl && ttl < 0
33
+ # For deletion, set both to past date
34
+ cookie_opts[:max_age] = 0
35
+ cookie_opts[:expires] = Time.now.utc - 86400
36
+ end
37
+
38
+ # Set the cookie value
39
+ cookie_opts[:value] = value
40
+
41
+ # Validate SameSite attribute
42
+ valid_samesite = [:strict, :lax, :none, 'Strict', 'Lax', 'None']
43
+ unless valid_samesite.include?(cookie_opts[:samesite])
44
+ cookie_opts[:samesite] = :lax
45
+ end
46
+
47
+ # If SameSite=None, Secure must be true
48
+ if cookie_opts[:samesite].to_s.downcase == 'none'
49
+ cookie_opts[:secure] = true
50
+ end
51
+
52
+ set_cookie name, cookie_opts
17
53
  end
18
- def delete_cookie name
19
- send_cookie name, nil, -1.day
54
+
55
+ def delete_cookie(name, opts = {})
56
+ # Ensure we use the same path and domain when deleting
57
+ delete_opts = {
58
+ path: opts[:path] || '/',
59
+ domain: opts[:domain],
60
+ secure: opts[:secure],
61
+ httponly: opts[:httponly],
62
+ samesite: opts[:samesite]
63
+ }.compact
64
+
65
+ send_cookie name, '', -1, delete_opts
66
+ end
67
+
68
+ def send_session_cookie(name, value, opts = {})
69
+ # Session cookies don't have expiration
70
+ session_opts = opts.merge(
71
+ secure: true,
72
+ httponly: true,
73
+ samesite: :lax
74
+ )
75
+
76
+ # Remove expiration-related options for session cookies
77
+ session_opts.delete(:max_age)
78
+ session_opts.delete(:expires)
79
+
80
+ # Adjust secure flag for local development
81
+ if request.local?
82
+ session_opts[:secure] = false
83
+ end
84
+
85
+ session_opts[:value] = value
86
+ set_cookie name, session_opts
87
+ end
88
+
89
+ def cookie_security_headers
90
+ # Add security headers that complement cookie security
91
+ headers = {}
92
+
93
+ # Prevent MIME type sniffing
94
+ headers['x-content-type-options'] = 'nosniff'
95
+
96
+ # Add referrer policy
97
+ headers['referrer-policy'] = 'strict-origin-when-cross-origin'
98
+
99
+ # Add frame options
100
+ headers['x-frame-options'] = 'DENY'
101
+
102
+ # Add XSS protection
103
+ headers['x-xss-protection'] = '1; mode=block'
104
+
105
+ headers
20
106
  end
21
107
  end
22
108
  end
data/lib/otto/route.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # lib/otto/route.rb
1
2
 
2
3
  class Otto
3
4
  # Otto::Route
@@ -6,6 +7,12 @@ class Otto
6
7
  # that path is requested. Each route represents a single line in a
7
8
  # routes file.
8
9
  #
10
+ # Routes include built-in security features:
11
+ # - Class name validation to prevent code injection
12
+ # - Automatic security header injection
13
+ # - CSRF protection when enabled
14
+ # - Input validation and sanitization
15
+ #
9
16
  # e.g.
10
17
  #
11
18
  # GET /uri/path YourApp.method
@@ -18,8 +25,17 @@ class Otto
18
25
  end
19
26
  attr_reader :verb, :path, :pattern, :method, :klass, :name, :definition, :keys, :kind
20
27
  attr_accessor :otto
21
- def initialize verb, path, definition
22
- @verb, @path, @definition = verb.to_s.upcase.to_sym, path, definition
28
+
29
+ # Initialize a new route with security validations
30
+ #
31
+ # @param verb [String] HTTP verb (GET, POST, PUT, DELETE, etc.)
32
+ # @param path [String] URL path pattern with optional parameters
33
+ # @param definition [String] Class and method definition (Class.method or Class#method)
34
+ # @raise [ArgumentError] if definition format is invalid or class name is unsafe
35
+ def initialize(verb, path, definition)
36
+ @verb = verb.to_s.upcase.to_sym
37
+ @path = path
38
+ @definition = definition
23
39
  @pattern, @keys = *compile(@path)
24
40
  if !@definition.index('.').nil?
25
41
  @klass, @name = @definition.split('.')
@@ -30,24 +46,97 @@ class Otto
30
46
  else
31
47
  raise ArgumentError, "Bad definition: #{@definition}"
32
48
  end
33
- @klass = eval(@klass)
34
- #@method = eval(@klass).method(@name)
49
+ @klass = safe_const_get(@klass)
50
+ # @method = @klass.method(@name)
51
+ end
52
+
53
+ private
54
+
55
+ # Safely resolve a class name using Object.const_get with security validations
56
+ # This replaces the previous eval() usage to prevent code injection attacks.
57
+ #
58
+ # Security features:
59
+ # - Validates class name format (must start with capital letter)
60
+ # - Prevents access to dangerous system classes
61
+ # - Blocks relative class references (starting with ::)
62
+ # - Provides clear error messages for debugging
63
+ #
64
+ # @param class_name [String] The class name to resolve
65
+ # @return [Class] The resolved class
66
+ # @raise [ArgumentError] if class name is invalid, forbidden, or not found
67
+ def safe_const_get(class_name)
68
+ # Validate class name format
69
+ unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
70
+ raise ArgumentError, "Invalid class name format: #{class_name}"
71
+ end
72
+
73
+ # Prevent dangerous class names
74
+ forbidden_classes = %w[
75
+ Kernel Module Class Object BasicObject
76
+ File Dir IO Process System
77
+ Binding Proc Method UnboundMethod
78
+ Thread ThreadGroup Fiber
79
+ ObjectSpace GC
80
+ ]
81
+
82
+ if forbidden_classes.include?(class_name) || class_name.start_with?('::')
83
+ raise ArgumentError, "Forbidden class name: #{class_name}"
84
+ end
85
+
86
+ begin
87
+ Object.const_get(class_name)
88
+ rescue NameError => e
89
+ raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
90
+ end
35
91
  end
92
+
93
+ public
94
+
36
95
  def pattern_regexp
37
- Regexp.new(@path.gsub(/\/\*/, '/.+'))
96
+ Regexp.new(@path.gsub('/*', '/.+'))
38
97
  end
39
- def call(env, extra_params={})
98
+
99
+ # Execute the route by calling the associated class method
100
+ #
101
+ # This method handles the complete request/response cycle with built-in security:
102
+ # - Processes parameters through the security layer
103
+ # - Adds configured security headers to the response
104
+ # - Extends request/response with security helpers when enabled
105
+ # - Provides CSRF and validation helpers to the target class
106
+ #
107
+ # @param env [Hash] Rack environment hash
108
+ # @param extra_params [Hash] Additional parameters to merge (default: {})
109
+ # @return [Array] Rack response array [status, headers, body]
110
+ def call(env, extra_params = {})
40
111
  extra_params ||= {}
41
112
  req = Rack::Request.new(env)
42
113
  res = Rack::Response.new
43
114
  req.extend Otto::RequestHelpers
44
115
  res.extend Otto::ResponseHelpers
45
116
  res.request = req
117
+
118
+ # Process parameters through security layer
46
119
  req.params.merge! extra_params
47
120
  req.params.replace Otto::Static.indifferent_params(req.params)
121
+
122
+ # Add security headers
123
+ if otto.respond_to?(:security_config) && otto.security_config
124
+ otto.security_config.security_headers.each do |header, value|
125
+ res.headers[header] = value
126
+ end
127
+ end
128
+
48
129
  klass.extend Otto::Route::ClassMethods
49
- klass.otto = self.otto
50
- Otto.logger.debug "Route class: #{klass}"
130
+ klass.otto = otto
131
+
132
+ # Add security helpers if CSRF is enabled
133
+ if otto.respond_to?(:security_config) && otto.security_config&.csrf_enabled?
134
+ res.extend Otto::Security::CSRFHelpers
135
+ end
136
+
137
+ # Add validation helpers
138
+ res.extend Otto::Security::ValidationHelpers
139
+
51
140
  case kind
52
141
  when :instance
53
142
  inst = klass.new req, res
@@ -55,28 +144,31 @@ class Otto
55
144
  when :class
56
145
  klass.send(name, req, res)
57
146
  else
58
- raise RuntimeError, "Unsupported kind for #{@definition}: #{kind}"
147
+ raise "Unsupported kind for #{@definition}: #{kind}"
59
148
  end
60
149
  res.body = [res.body] unless res.body.respond_to?(:each)
61
150
  res.finish
62
151
  end
152
+
153
+ private
154
+
63
155
  # Brazenly borrowed from Sinatra::Base:
64
156
  # https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
65
157
  def compile(path)
66
158
  keys = []
67
159
  if path.respond_to? :to_str
68
- special_chars = %w{. + ( ) $}
160
+ special_chars = %w[. + ( ) $]
69
161
  pattern =
70
162
  path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
71
163
  case match
72
- when "*"
164
+ when '*'
73
165
  keys << 'splat'
74
- "(.*?)"
166
+ '(.*?)'
75
167
  when *special_chars
76
168
  Regexp.escape(match)
77
169
  else
78
- keys << $2[1..-1]
79
- "([^/?#]+)"
170
+ keys << ::Regexp.last_match(2)[1..-1]
171
+ '([^/?#]+)'
80
172
  end
81
173
  end
82
174
  # Wrap the regex in parens so the regex works properly.
@@ -0,0 +1,316 @@
1
+ # lib/otto/security/config.rb
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+
6
+ class Otto
7
+ module Security
8
+ # Security configuration for Otto applications
9
+ #
10
+ # This class manages all security-related settings including CSRF protection,
11
+ # input validation, trusted proxies, and security headers. Security features
12
+ # are disabled by default for backward compatibility.
13
+ #
14
+ # @example Basic usage
15
+ # config = Otto::Security::Config.new
16
+ # config.enable_csrf_protection!
17
+ # config.add_trusted_proxy('10.0.0.0/8')
18
+ #
19
+ # @example Custom limits
20
+ # config = Otto::Security::Config.new
21
+ # config.max_request_size = 5 * 1024 * 1024 # 5MB
22
+ # config.max_param_depth = 16
23
+ class Config
24
+ attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
25
+ :max_request_size, :max_param_depth, :max_param_keys,
26
+ :trusted_proxies, :require_secure_cookies,
27
+ :security_headers, :input_validation
28
+
29
+ # Initialize security configuration with safe defaults
30
+ #
31
+ # All security features are disabled by default to maintain backward
32
+ # compatibility with existing Otto applications.
33
+ def initialize
34
+ @csrf_protection = false
35
+ @csrf_token_key = '_csrf_token'
36
+ @csrf_header_key = 'HTTP_X_CSRF_TOKEN'
37
+ @csrf_session_key = '_csrf_session_id'
38
+ @max_request_size = 10 * 1024 * 1024 # 10MB
39
+ @max_param_depth = 32
40
+ @max_param_keys = 64
41
+ @trusted_proxies = []
42
+ @require_secure_cookies = false
43
+ @security_headers = default_security_headers
44
+ @input_validation = true
45
+ end
46
+
47
+ # Enable CSRF (Cross-Site Request Forgery) protection
48
+ #
49
+ # When enabled, Otto will:
50
+ # - Generate CSRF tokens for safe HTTP methods (GET, HEAD, OPTIONS, TRACE)
51
+ # - Validate CSRF tokens for unsafe methods (POST, PUT, DELETE, PATCH)
52
+ # - Automatically inject CSRF meta tags into HTML responses
53
+ # - Provide helper methods for forms and AJAX requests
54
+ #
55
+ # @return [void]
56
+ def enable_csrf_protection!
57
+ @csrf_protection = true
58
+ end
59
+
60
+ # Disable CSRF protection
61
+ #
62
+ # @return [void]
63
+ def disable_csrf_protection!
64
+ @csrf_protection = false
65
+ end
66
+
67
+ # Check if CSRF protection is currently enabled
68
+ #
69
+ # @return [Boolean] true if CSRF protection is enabled
70
+ def csrf_enabled?
71
+ @csrf_protection
72
+ end
73
+
74
+ # Add a trusted proxy server for accurate client IP detection
75
+ #
76
+ # Only requests from trusted proxies will have their X-Forwarded-For
77
+ # and similar headers honored for IP detection. This prevents IP spoofing
78
+ # from untrusted sources.
79
+ #
80
+ # @param proxy [String, Array] IP address, CIDR range, or array of addresses
81
+ # @raise [ArgumentError] if proxy is not a String or Array
82
+ # @return [void]
83
+ #
84
+ # @example Add single proxy
85
+ # config.add_trusted_proxy('10.0.0.1')
86
+ #
87
+ # @example Add CIDR range
88
+ # config.add_trusted_proxy('192.168.0.0/16')
89
+ #
90
+ # @example Add multiple proxies
91
+ # config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
92
+ def add_trusted_proxy(proxy)
93
+ case proxy
94
+ when String
95
+ @trusted_proxies << proxy
96
+ when Array
97
+ @trusted_proxies.concat(proxy)
98
+ else
99
+ raise ArgumentError, "Proxy must be a String or Array"
100
+ end
101
+ end
102
+
103
+ # Check if an IP address is from a trusted proxy
104
+ #
105
+ # @param ip [String] IP address to check
106
+ # @return [Boolean] true if the IP is from a trusted proxy
107
+ def trusted_proxy?(ip)
108
+ return false if @trusted_proxies.empty?
109
+
110
+ @trusted_proxies.any? do |proxy|
111
+ case proxy
112
+ when String
113
+ ip == proxy || ip.start_with?(proxy)
114
+ when Regexp
115
+ proxy.match?(ip)
116
+ else
117
+ false
118
+ end
119
+ end
120
+ end
121
+
122
+ # Validate that a request size is within acceptable limits
123
+ #
124
+ # @param content_length [String, Integer, nil] Content-Length header value
125
+ # @raise [Otto::Security::RequestTooLargeError] if request exceeds maximum size
126
+ # @return [Boolean] true if request size is acceptable
127
+ def validate_request_size(content_length)
128
+ return true if content_length.nil?
129
+
130
+ size = content_length.to_i
131
+ if size > @max_request_size
132
+ raise Otto::Security::RequestTooLargeError,
133
+ "Request size #{size} exceeds maximum #{@max_request_size}"
134
+ end
135
+ true
136
+ end
137
+
138
+ def generate_csrf_token(session_id = nil)
139
+ base = session_id || 'no-session'
140
+ token = SecureRandom.hex(32)
141
+ hash_input = base + ':' + token
142
+ signature = Digest::SHA256.hexdigest(hash_input)
143
+ csrf_token = "#{token}:#{signature}"
144
+
145
+ puts "=== CSRF Generation ==="
146
+ puts "hash_input: #{hash_input.inspect}"
147
+ puts "signature: #{signature}"
148
+ puts "csrf_token: #{csrf_token}"
149
+
150
+ csrf_token
151
+ end
152
+
153
+ def verify_csrf_token(token, session_id = nil)
154
+ return false if token.nil? || token.empty?
155
+
156
+ token_part, signature = token.split(':')
157
+ return false if token_part.nil? || signature.nil?
158
+
159
+ base = session_id || 'no-session'
160
+ hash_input = "#{base}:#{token_part}"
161
+ expected_signature = Digest::SHA256.hexdigest(hash_input)
162
+ comparison_result = secure_compare(signature, expected_signature)
163
+
164
+ puts "=== CSRF Verification ==="
165
+ puts "hash_input: #{hash_input.inspect}"
166
+ puts "received_signature: #{signature}"
167
+ puts "expected_signature: #{expected_signature}"
168
+ puts "match: #{comparison_result}"
169
+
170
+ comparison_result
171
+ end
172
+
173
+ # Enable HTTP Strict Transport Security (HSTS) header
174
+ #
175
+ # HSTS forces browsers to use HTTPS for all future requests to this domain.
176
+ # WARNING: This can make your domain inaccessible if HTTPS is not properly
177
+ # configured. Only enable this when you're certain HTTPS is working correctly.
178
+ #
179
+ # @param max_age [Integer] Maximum age in seconds (default: 1 year)
180
+ # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
181
+ # @return [void]
182
+ def enable_hsts!(max_age: 31536000, include_subdomains: true)
183
+ hsts_value = "max-age=#{max_age}"
184
+ hsts_value += "; includeSubDomains" if include_subdomains
185
+ @security_headers['strict-transport-security'] = hsts_value
186
+ end
187
+
188
+ # Enable Content Security Policy (CSP) header
189
+ #
190
+ # CSP helps prevent XSS attacks by controlling which resources can be loaded.
191
+ # The default policy only allows resources from the same origin.
192
+ #
193
+ # @param policy [String] CSP policy string (default: "default-src 'self'")
194
+ # @return [void]
195
+ #
196
+ # @example Custom policy
197
+ # config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
198
+ def enable_csp!(policy = "default-src 'self'")
199
+ @security_headers['content-security-policy'] = policy
200
+ end
201
+
202
+ # Enable X-Frame-Options header to prevent clickjacking
203
+ #
204
+ # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
205
+ # @return [void]
206
+ def enable_frame_protection!(option = 'SAMEORIGIN')
207
+ @security_headers['x-frame-options'] = option
208
+ end
209
+
210
+ # Set custom security headers
211
+ #
212
+ # @param headers [Hash] Hash of header name => value pairs
213
+ # @return [void]
214
+ #
215
+ # @example
216
+ # config.set_custom_headers({
217
+ # 'permissions-policy' => 'geolocation=(), microphone=()',
218
+ # 'cross-origin-opener-policy' => 'same-origin'
219
+ # })
220
+ def set_custom_headers(headers)
221
+ @security_headers.merge!(headers)
222
+ end
223
+
224
+ def get_or_create_session_id(request)
225
+ # Try existing sources first
226
+ session_id = extract_existing_session_id(request)
227
+
228
+ # Create and persist if none found
229
+ if session_id.nil? || session_id.empty?
230
+ session_id = SecureRandom.hex(16)
231
+ store_session_id(request, session_id)
232
+ end
233
+
234
+ session_id
235
+ end
236
+
237
+ private
238
+
239
+ def extract_existing_session_id(request)
240
+ # Try session first
241
+ begin
242
+ session = request.session
243
+ if session
244
+ return session.id if session.respond_to?(:id) && session.id
245
+ return session[csrf_session_key] if session[csrf_session_key]
246
+ return session['session_id'] if session['session_id']
247
+ end
248
+ rescue
249
+ # Fall through to cookies
250
+ end
251
+
252
+ # Try cookies
253
+ request.cookies['_otto_session'] ||
254
+ request.cookies['session_id'] ||
255
+ request.cookies['_session_id']
256
+ end
257
+
258
+ def store_session_id(request, session_id)
259
+ begin
260
+ session = request.session
261
+ session[csrf_session_key] = session_id if session
262
+ rescue
263
+ # Cookie fallback handled in inject_csrf_token
264
+ end
265
+ end
266
+
267
+ private
268
+
269
+ # Default security headers applied to all responses
270
+ #
271
+ # These headers provide basic defense against common web vulnerabilities:
272
+ # - x-content-type-options: Prevents MIME type sniffing
273
+ # - x-xss-protection: Enables browser XSS filtering (legacy browsers)
274
+ # - referrer-policy: Controls referrer information leakage
275
+ #
276
+ # Note: Restrictive headers like HSTS, CSP, and X-Frame-Options are not
277
+ # included by default to avoid breaking downstream applications. These
278
+ # should be configured explicitly when appropriate.
279
+ #
280
+ # @return [Hash] Hash of header names and values (all lowercase for Rack 3+)
281
+ def default_security_headers
282
+ {
283
+ 'x-content-type-options' => 'nosniff',
284
+ 'x-xss-protection' => '1; mode=block',
285
+ 'referrer-policy' => 'strict-origin-when-cross-origin'
286
+ }
287
+ end
288
+
289
+ # Perform constant-time string comparison to prevent timing attacks
290
+ #
291
+ # This method compares two strings in constant time regardless of where
292
+ # they differ, preventing attackers from using timing differences to
293
+ # deduce information about secret values.
294
+ #
295
+ # @param a [String, nil] First string to compare
296
+ # @param b [String, nil] Second string to compare
297
+ # @return [Boolean] true if strings are equal
298
+ def secure_compare(a, b)
299
+ return false if a.nil? || b.nil? || a.length != b.length
300
+
301
+ result = 0
302
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
303
+ result == 0
304
+ end
305
+ end
306
+
307
+ # Raised when a request exceeds the configured size limit
308
+ class RequestTooLargeError < StandardError; end
309
+
310
+ # Raised when CSRF token validation fails
311
+ class CSRFError < StandardError; end
312
+
313
+ # Raised when input validation fails (XSS, SQL injection, etc.)
314
+ class ValidationError < StandardError; end
315
+ end
316
+ end