otto 1.3.0 → 1.5.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,27 @@
1
+ # lib/otto/helpers/base.rb
2
+
3
+ class Otto
4
+ module BaseHelpers
5
+ # Build application path by joining path segments
6
+ #
7
+ # This method safely joins multiple path segments, handling
8
+ # duplicate slashes and ensuring proper path formatting.
9
+ # Includes the script name (mount point) as the first segment.
10
+ #
11
+ # @param paths [Array<String>] Path segments to join
12
+ # @return [String] Properly formatted path
13
+ #
14
+ # @example
15
+ # app_path('api', 'v1', 'users')
16
+ # # => "/myapp/api/v1/users"
17
+ #
18
+ # @example
19
+ # app_path(['admin', 'settings'])
20
+ # # => "/myapp/admin/settings"
21
+ def app_path(*paths)
22
+ paths = paths.flatten.compact
23
+ paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
24
+ paths.join('/').gsub('//', '/')
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,11 @@
1
1
  # lib/otto/helpers/request.rb
2
2
 
3
+ require_relative 'base'
4
+
3
5
  class Otto
4
6
  module RequestHelpers
7
+ include Otto::BaseHelpers
8
+
5
9
  def user_agent
6
10
  env['HTTP_USER_AGENT']
7
11
  end
@@ -70,7 +74,11 @@ class Otto
70
74
  ip = client_ipaddress
71
75
  return false unless ip
72
76
 
73
- local_or_private_ip?(ip)
77
+ # Check both IP and server name for comprehensive localhost detection
78
+ server_name = env['SERVER_NAME']
79
+ local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']
80
+
81
+ local_or_private_ip?(ip) && local_server_names.include?(server_name)
74
82
  end
75
83
 
76
84
  def secure?
@@ -107,8 +115,6 @@ class Otto
107
115
  [prefix, http_host, request_path].join
108
116
  end
109
117
 
110
- private
111
-
112
118
  def otto_security_config
113
119
  # Try to get security config from various sources
114
120
  if respond_to?(:otto) && otto.respond_to?(:security_config)
@@ -136,7 +142,7 @@ class Otto
136
142
 
137
143
  # Validate each octet
138
144
  octets = clean_ip.split('.')
139
- return nil unless octets.all? { |octet| (0..255).include?(octet.to_i) }
145
+ return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
140
146
 
141
147
  clean_ip
142
148
  end
@@ -166,5 +172,218 @@ class Otto
166
172
  # Check for private IP ranges
167
173
  private_ip?(ip)
168
174
  end
175
+
176
+ # Collect and format HTTP header details from the request environment
177
+ #
178
+ # This method extracts and formats specific HTTP headers, including
179
+ # Cloudflare and proxy-related headers, for logging and debugging purposes.
180
+ #
181
+ # @param header_prefix [String, nil] Custom header prefix to include (e.g. 'X_SECRET_')
182
+ # @param additional_keys [Array<String>] Additional header keys to collect
183
+ # @return [String] Formatted header details as "key: value" pairs
184
+ #
185
+ # @example Basic usage
186
+ # collect_proxy_headers
187
+ # # => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"
188
+ #
189
+ #
190
+ # @example With custom prefix
191
+ # collect_proxy_headers(header_prefix: 'X_CUSTOM_')
192
+ # # => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"
193
+ def collect_proxy_headers(header_prefix: nil, additional_keys: [])
194
+ keys = %w[
195
+ HTTP_FLY_REQUEST_ID
196
+ HTTP_VIA
197
+ HTTP_X_FORWARDED_PROTO
198
+ HTTP_X_FORWARDED_FOR
199
+ HTTP_X_FORWARDED_HOST
200
+ HTTP_X_FORWARDED_PORT
201
+ HTTP_X_SCHEME
202
+ HTTP_X_REAL_IP
203
+ HTTP_CF_IPCOUNTRY
204
+ HTTP_CF_RAY
205
+ REMOTE_ADDR
206
+ ]
207
+
208
+ # Add any header that begins with the specified prefix
209
+ if header_prefix
210
+ prefix_keys = env.keys.select { |key| key.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
211
+ keys.concat(prefix_keys)
212
+ end
213
+
214
+ # Add any additional keys requested
215
+ keys.concat(additional_keys) if additional_keys.any?
216
+
217
+ keys.sort.filter_map do |key|
218
+ value = env[key]
219
+ next unless value
220
+
221
+ # Normalize the header name to look like browser dev console
222
+ # e.g. Content-Type instead of HTTP_CONTENT_TYPE
223
+ pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
224
+ "#{pretty_name}: #{value}"
225
+ end.join(' ')
226
+ end
227
+
228
+ # Format request details as a single string for logging
229
+ #
230
+ # This method combines IP address, HTTP method, path, query parameters,
231
+ # and proxy header details into a single formatted string suitable for logging.
232
+ #
233
+ # @param header_prefix [String, nil] Custom header prefix for proxy headers
234
+ # @return [String] Formatted request details
235
+ #
236
+ # @example
237
+ # format_request_details
238
+ # # => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"
239
+ #
240
+ def format_request_details(header_prefix: nil)
241
+ header_details = collect_proxy_headers(header_prefix: header_prefix)
242
+
243
+ details = [
244
+ client_ipaddress,
245
+ "#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
246
+ "Proxy[#{header_details}]",
247
+ ]
248
+
249
+ details.join('; ')
250
+ end
251
+
252
+ # Check if user agent matches blocked patterns
253
+ #
254
+ # This method checks if the current request's user agent string
255
+ # matches any of the provided blocked agent patterns.
256
+ #
257
+ # @param blocked_agents [Array<String, Symbol, Regexp>] Patterns to check against
258
+ # @return [Boolean] true if user agent is allowed, false if blocked
259
+ #
260
+ # @example
261
+ # blocked_user_agent?([:bot, :crawler, 'BadAgent'])
262
+ # # => false if user agent contains 'bot', 'crawler', or 'BadAgent'
263
+ def blocked_user_agent?(blocked_agents: [])
264
+ return true if blocked_agents.empty?
265
+
266
+ user_agent_string = user_agent.to_s.downcase
267
+ return true if user_agent_string.empty?
268
+
269
+ blocked_agents.flatten.any? do |agent|
270
+ case agent
271
+ when Regexp
272
+ user_agent_string.match?(agent)
273
+ else
274
+ user_agent_string.include?(agent.to_s.downcase)
275
+ end
276
+ end
277
+ end
278
+
279
+ # Build application path by joining path segments
280
+ #
281
+ # This method safely joins multiple path segments, handling
282
+ # duplicate slashes and ensuring proper path formatting.
283
+ # Includes the script name (mount point) as the first segment.
284
+ #
285
+ # @param paths [Array<String>] Path segments to join
286
+ # @return [String] Properly formatted path
287
+ #
288
+ # @example
289
+ # app_path('api', 'v1', 'users')
290
+ # # => "/myapp/api/v1/users"
291
+ #
292
+ # @example
293
+ # app_path(['admin', 'settings'])
294
+ # # => "/myapp/admin/settings"
295
+ def app_path(*paths)
296
+ paths = paths.flatten.compact
297
+ paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
298
+ paths.join('/').gsub('//', '/')
299
+ end
300
+
301
+ # Set the locale for the request based on multiple sources
302
+ #
303
+ # This method determines the locale to be used for the request by checking
304
+ # the following sources in order of precedence:
305
+ # 1. The locale parameter passed to the method
306
+ # 2. The locale query parameter in the request
307
+ # 3. The user's saved locale preference (if provided)
308
+ # 4. The rack.locale environment variable
309
+ #
310
+ # If a valid locale is found, it's stored in the request environment.
311
+ # If no valid locale is found, the default locale is used.
312
+ #
313
+ # @param locale [String, nil] The locale to use, if specified
314
+ # @param opts [Hash] Configuration options
315
+ # @option opts [Hash] :available_locales Hash of available locales to validate against (required unless configured at Otto level)
316
+ # @option opts [String] :default_locale Default locale to use as fallback (required unless configured at Otto level)
317
+ # @option opts [String, nil] :preferred_locale User's saved locale preference
318
+ # @option opts [String] :locale_env_key Environment key to store the locale (default: 'locale')
319
+ # @option opts [Boolean] :debug Enable debug logging for locale selection
320
+ # @return [String] The selected locale
321
+ #
322
+ # @example Basic usage
323
+ # check_locale!(
324
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish' },
325
+ # default_locale: 'en'
326
+ # )
327
+ # # => 'en'
328
+ #
329
+ # @example With user preference
330
+ # check_locale!(nil, {
331
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish' },
332
+ # default_locale: 'en',
333
+ # preferred_locale: 'es'
334
+ # })
335
+ # # => 'es'
336
+ #
337
+ # @example Using Otto-level configuration
338
+ # # Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
339
+ # check_locale!('es') # Uses Otto's config automatically
340
+ # # => 'es'
341
+ #
342
+ def check_locale!(locale = nil, opts = {})
343
+ # Get configuration from options, Otto config, or environment (in that order)
344
+ otto_config = env['otto.locale_config']
345
+
346
+ available_locales = opts[:available_locales] ||
347
+ otto_config&.dig(:available_locales) ||
348
+ env['otto.available_locales']
349
+ default_locale = opts[:default_locale] ||
350
+ otto_config&.dig(:default_locale) ||
351
+ env['otto.default_locale']
352
+ preferred_locale = opts[:preferred_locale]
353
+ locale_env_key = opts[:locale_env_key] || 'locale'
354
+ debug_enabled = opts[:debug] || false
355
+
356
+ # Guard clause - required configuration must be present
357
+ unless available_locales && default_locale
358
+ raise ArgumentError, 'available_locales and default_locale are required (provide via opts or Otto configuration)'
359
+ end
360
+
361
+ # Check sources in order of precedence
362
+ locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
363
+ locale ||= preferred_locale if preferred_locale
364
+ locale ||= (env['rack.locale'] || []).first
365
+
366
+ # Validate locale against available translations
367
+ have_translations = locale && available_locales.key?(locale.to_s)
368
+
369
+ # Debug logging if enabled
370
+ if debug_enabled && defined?(Otto.logger)
371
+ message = format(
372
+ '[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
373
+ locale,
374
+ env.dig('rack.request.query_hash', 'locale'),
375
+ preferred_locale,
376
+ (env['rack.locale'] || []).first,
377
+ have_translations
378
+ )
379
+ Otto.logger.debug message
380
+ end
381
+
382
+ # Set the locale in request environment
383
+ selected_locale = have_translations ? locale : default_locale
384
+ env[locale_env_key] = selected_locale
385
+
386
+ selected_locale
387
+ end
169
388
  end
170
389
  end
@@ -1,7 +1,11 @@
1
1
  # lib/otto/helpers/response.rb
2
2
 
3
+ require_relative 'base'
4
+
3
5
  class Otto
4
6
  module ResponseHelpers
7
+ include Otto::BaseHelpers
8
+
5
9
  attr_accessor :request
6
10
 
7
11
  def send_secure_cookie(name, value, ttl, opts = {})
@@ -76,5 +80,76 @@ class Otto
76
80
 
77
81
  headers
78
82
  end
83
+
84
+ # Set Content Security Policy (CSP) headers with nonce support
85
+ #
86
+ # This method generates and sets CSP headers with the provided nonce value,
87
+ # following the same usage pattern as send_cookie methods. The CSP policy
88
+ # is generated dynamically based on the security configuration and environment.
89
+ #
90
+ # @param content_type [String] Content-Type header value to set
91
+ # @param nonce [String] Nonce value to include in CSP directives
92
+ # @param opts [Hash] Options for CSP generation
93
+ # @option opts [Otto::Security::Config] :security_config Security config to use
94
+ # @option opts [Boolean] :development_mode Use development-friendly CSP directives
95
+ # @option opts [Boolean] :debug Enable debug logging for this request
96
+ # @return [void]
97
+ #
98
+ # @example Basic usage
99
+ # nonce = SecureRandom.base64(16)
100
+ # res.send_csp_headers('text/html; charset=utf-8', nonce)
101
+ #
102
+ # @example With options
103
+ # res.send_csp_headers('text/html; charset=utf-8', nonce, {
104
+ # development_mode: Rails.env.development?,
105
+ # debug: true
106
+ # })
107
+ def send_csp_headers(content_type, nonce, opts = {})
108
+ # Set content type if not already set
109
+ headers['content-type'] ||= content_type
110
+
111
+ # Warn if CSP header already exists but don't skip
112
+ if headers['content-security-policy']
113
+ warn 'CSP header already set, overriding with nonce-based policy'
114
+ end
115
+
116
+ # Get security configuration
117
+ security_config = opts[:security_config] ||
118
+ (request&.env && request.env['otto.security_config']) ||
119
+ nil
120
+
121
+ # Skip if CSP nonce support is not enabled
122
+ return unless security_config&.csp_nonce_enabled?
123
+
124
+ # Generate CSP policy with nonce
125
+ development_mode = opts[:development_mode] || false
126
+ csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
127
+
128
+ # Debug logging if enabled
129
+ debug_enabled = opts[:debug] || security_config.debug_csp?
130
+ if debug_enabled && defined?(Otto.logger)
131
+ Otto.logger.debug "[CSP] #{csp_policy}"
132
+ end
133
+
134
+ # Set the CSP header
135
+ headers['content-security-policy'] = csp_policy
136
+ end
137
+
138
+ # Set cache control headers to prevent caching
139
+ #
140
+ # This method sets comprehensive cache control headers to ensure that
141
+ # the response is not cached by browsers, proxies, or CDNs. This is
142
+ # particularly useful for sensitive pages or dynamic content that
143
+ # should always be fresh.
144
+ #
145
+ # @return [void]
146
+ #
147
+ # @example
148
+ # res.no_cache!
149
+ def no_cache!
150
+ headers['cache-control'] = 'no-store, no-cache, must-revalidate, max-age=0'
151
+ headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
152
+ headers['pragma'] = 'no-cache'
153
+ end
79
154
  end
80
155
  end
@@ -0,0 +1,141 @@
1
+ # lib/otto/response_handlers.rb
2
+
3
+ class Otto
4
+ module ResponseHandlers
5
+ # Base response handler class
6
+ class BaseHandler
7
+ def self.handle(result, response, context = {})
8
+ raise NotImplementedError, "Subclasses must implement handle method"
9
+ end
10
+
11
+ protected
12
+
13
+ def self.ensure_status_set(response, default_status = 200)
14
+ response.status = default_status unless response.status && response.status != 0
15
+ end
16
+ end
17
+
18
+ # Handler for JSON responses
19
+ class JSONHandler < BaseHandler
20
+ def self.handle(result, response, context = {})
21
+ response['Content-Type'] = 'application/json'
22
+
23
+ # Determine the data to serialize
24
+ data = if context[:logic_instance]&.respond_to?(:response_data)
25
+ context[:logic_instance].response_data
26
+ elsif result.is_a?(Hash)
27
+ result
28
+ elsif result.nil?
29
+ { success: true }
30
+ else
31
+ { success: true, data: result }
32
+ end
33
+
34
+ response.body = [JSON.generate(data)]
35
+ ensure_status_set(response, context[:status_code] || 200)
36
+ end
37
+ end
38
+
39
+ # Handler for redirect responses
40
+ class RedirectHandler < BaseHandler
41
+ def self.handle(result, response, context = {})
42
+ # Determine redirect path
43
+ path = if context[:redirect_path]
44
+ context[:redirect_path]
45
+ elsif context[:logic_instance]&.respond_to?(:redirect_path)
46
+ context[:logic_instance].redirect_path
47
+ elsif result.is_a?(String)
48
+ result
49
+ else
50
+ '/'
51
+ end
52
+
53
+ response.redirect(path)
54
+ end
55
+ end
56
+
57
+ # Handler for view/template responses
58
+ class ViewHandler < BaseHandler
59
+ def self.handle(result, response, context = {})
60
+ if context[:logic_instance]&.respond_to?(:view)
61
+ response.body = [context[:logic_instance].view.render]
62
+ response['Content-Type'] = 'text/html' unless response['Content-Type']
63
+ elsif result.respond_to?(:to_s)
64
+ response.body = [result.to_s]
65
+ response['Content-Type'] = 'text/html' unless response['Content-Type']
66
+ else
67
+ response.body = ['']
68
+ end
69
+
70
+ ensure_status_set(response, context[:status_code] || 200)
71
+ end
72
+ end
73
+
74
+ # Default handler that preserves existing Otto behavior
75
+ class DefaultHandler < BaseHandler
76
+ def self.handle(result, response, context = {})
77
+ # Otto's default behavior - let the route handler manage the response
78
+ # This handler does nothing, preserving existing behavior
79
+ ensure_status_set(response, 200)
80
+ end
81
+ end
82
+
83
+ # Auto-detection handler that chooses appropriate handler based on context
84
+ class AutoHandler < BaseHandler
85
+ def self.handle(result, response, context = {})
86
+ # Auto-detect based on result type and request context
87
+ handler_class = detect_handler_type(result, response, context)
88
+ handler_class.handle(result, response, context)
89
+ end
90
+
91
+ private
92
+
93
+ def self.detect_handler_type(result, response, context)
94
+ # Check if response type was already set by the handler
95
+ content_type = response['Content-Type']
96
+
97
+ if content_type&.include?('application/json')
98
+ JSONHandler
99
+ elsif (context[:logic_instance]&.respond_to?(:redirect_path) && context[:logic_instance]&.redirect_path) ||
100
+ (result.is_a?(String) && result.match?(%r{^/}))
101
+ # Logic instance has redirect path or result is a string path
102
+ RedirectHandler
103
+ elsif result.is_a?(Hash)
104
+ JSONHandler
105
+ elsif context[:logic_instance]&.respond_to?(:view)
106
+ ViewHandler
107
+ else
108
+ DefaultHandler
109
+ end
110
+ end
111
+ end
112
+
113
+ # Factory for creating response handlers
114
+ class HandlerFactory
115
+ # Map of response type names to handler classes
116
+ HANDLER_MAP = {
117
+ 'json' => JSONHandler,
118
+ 'redirect' => RedirectHandler,
119
+ 'view' => ViewHandler,
120
+ 'auto' => AutoHandler,
121
+ 'default' => DefaultHandler
122
+ }.freeze
123
+
124
+ def self.create_handler(response_type)
125
+ handler_class = HANDLER_MAP[response_type.to_s.downcase]
126
+
127
+ unless handler_class
128
+ Otto.logger.warn "Unknown response type: #{response_type}, falling back to default" if Otto.debug
129
+ handler_class = DefaultHandler
130
+ end
131
+
132
+ handler_class
133
+ end
134
+
135
+ def self.handle_response(result, response, response_type, context = {})
136
+ handler = create_handler(response_type)
137
+ handler.handle(result, response, context)
138
+ end
139
+ end
140
+ end
141
+ end