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.
@@ -13,7 +13,7 @@ require_relative '../../lib/otto'
13
13
  require_relative 'app'
14
14
 
15
15
  # Create Otto app with security features enabled
16
- app = Otto.new("./routes", {
16
+ app = Otto.new('./routes', {
17
17
  # Enable CSRF protection for POST, PUT, DELETE requests
18
18
  csrf_protection: true,
19
19
 
@@ -42,14 +42,15 @@ app = Otto.new("./routes", {
42
42
  security_headers: {
43
43
  'content-security-policy' => "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'",
44
44
  'strict-transport-security' => 'max-age=31536000; includeSubDomains',
45
- 'x-frame-options' => 'DENY'
46
- }
47
- })
45
+ 'x-frame-options' => 'DENY',
46
+ },
47
+ }
48
+ )
48
49
 
49
50
  # Optional: Configure additional security settings
50
51
  app.security_config.max_request_size = 5 * 1024 * 1024 # 5MB limit
51
- app.security_config.max_param_depth = 10 # Limit parameter nesting
52
- app.security_config.max_param_keys = 50 # Limit parameters per request
52
+ app.security_config.max_param_depth = 10 # Limit parameter nesting
53
+ app.security_config.max_param_keys = 50 # Limit parameters per request
53
54
 
54
55
  # Optional: Add static file serving with security
55
56
  app.option[:public] = public_path
@@ -62,20 +63,21 @@ if ENV['RACK_ENV'] == 'production'
62
63
  # More restrictive CSP for production
63
64
  app.set_security_headers({
64
65
  'content-security-policy' => "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'",
65
- 'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload'
66
- })
66
+ 'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload',
67
+ },
68
+ )
67
69
  else
68
70
  # Development-specific settings
69
- puts "🔒 Security features enabled:"
70
- puts " ✓ CSRF Protection"
71
- puts " ✓ Input Validation"
72
- puts " ✓ Request Size Limits"
73
- puts " ✓ Security Headers"
74
- puts " ✓ Trusted Proxy Support"
75
- puts ""
71
+ puts '🔒 Security features enabled:'
72
+ puts ' ✓ CSRF Protection'
73
+ puts ' ✓ Input Validation'
74
+ puts ' ✓ Request Size Limits'
75
+ puts ' ✓ Security Headers'
76
+ puts ' ✓ Trusted Proxy Support'
77
+ puts ''
76
78
  end
77
79
 
78
80
  # Mount the application
79
- map('/') {
81
+ map('/') do
80
82
  run app
81
- }
83
+ end
@@ -1,11 +1,10 @@
1
1
  # lib/otto/design_system.rb
2
2
 
3
3
  class Otto
4
+ # Shared design system for Otto framework examples
5
+ # Provides consistent styling, components, and utilities
4
6
  module DesignSystem
5
- # Shared design system for Otto framework examples
6
- # Provides consistent styling, components, and utilities
7
-
8
- def otto_page(content, title = "Otto Framework", additional_head = "")
7
+ def otto_page(content, title = 'Otto Framework', additional_head = '')
9
8
  <<~HTML
10
9
  <!DOCTYPE html>
11
10
  <html lang="en">
@@ -25,9 +24,9 @@ class Otto
25
24
  HTML
26
25
  end
27
26
 
28
- def otto_input(name, type: "text", placeholder: "", value: "", required: false)
29
- req_attr = required ? "required" : ""
30
- val_attr = value.empty? ? "" : %{value="#{escape_html(value)}"}
27
+ def otto_input(name, type: 'text', placeholder: '', value: '', required: false)
28
+ req_attr = required ? 'required' : ''
29
+ val_attr = value.empty? ? '' : %(value="#{escape_html(value)}")
31
30
 
32
31
  <<~HTML
33
32
  <input
@@ -41,8 +40,8 @@ class Otto
41
40
  HTML
42
41
  end
43
42
 
44
- def otto_textarea(name, placeholder: "", value: "", rows: 4, required: false)
45
- req_attr = required ? "required" : ""
43
+ def otto_textarea(name, placeholder: '', value: '', rows: 4, required: false)
44
+ req_attr = required ? 'required' : ''
46
45
 
47
46
  <<~HTML
48
47
  <textarea
@@ -55,8 +54,8 @@ class Otto
55
54
  HTML
56
55
  end
57
56
 
58
- def otto_button(text, type: "submit", variant: "primary", size: "default")
59
- size_class = size == "small" ? "otto-btn-sm" : ""
57
+ def otto_button(text, type: 'submit', variant: 'primary', size: 'default')
58
+ size_class = size == 'small' ? 'otto-btn-sm' : ''
60
59
 
61
60
  <<~HTML
62
61
  <button type="#{type}" class="otto-btn otto-btn-#{variant} #{size_class}">
@@ -66,7 +65,7 @@ class Otto
66
65
  end
67
66
 
68
67
  def otto_alert(type, title, message, dismissible: false)
69
- dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' : ""
68
+ dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' : ''
70
69
 
71
70
  <<~HTML
72
71
  <div class="otto-alert otto-alert-#{type}">
@@ -77,9 +76,9 @@ class Otto
77
76
  HTML
78
77
  end
79
78
 
80
- def otto_card(title = nil, &block)
81
- content = block_given? ? yield : ""
82
- title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" : ""
79
+ def otto_card(title = nil, &)
80
+ content = block_given? ? yield : ''
81
+ title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" : ''
83
82
 
84
83
  <<~HTML
85
84
  <div class="otto-card">
@@ -90,7 +89,7 @@ class Otto
90
89
  end
91
90
 
92
91
  def otto_link(text, href, external: false)
93
- target_attr = external ? 'target="_blank" rel="noopener noreferrer"' : ""
92
+ target_attr = external ? 'target="_blank" rel="noopener noreferrer"' : ''
94
93
 
95
94
  <<~HTML
96
95
  <a href="#{escape_html(href)}" class="otto-link" #{target_attr}>
@@ -99,7 +98,7 @@ class Otto
99
98
  HTML
100
99
  end
101
100
 
102
- def otto_code_block(code, language = "")
101
+ def otto_code_block(code, language = '')
103
102
  <<~HTML
104
103
  <div class="otto-code-block">
105
104
  <pre><code class="language-#{language}">#{escape_html(code)}</code></pre>
@@ -111,12 +110,13 @@ class Otto
111
110
 
112
111
  def escape_html(text)
113
112
  return '' if text.nil?
113
+
114
114
  text.to_s
115
- .gsub('&', '&amp;')
116
- .gsub('<', '&lt;')
117
- .gsub('>', '&gt;')
118
- .gsub('"', '&quot;')
119
- .gsub("'", '&#x27;')
115
+ .gsub('&', '&amp;')
116
+ .gsub('<', '&lt;')
117
+ .gsub('>', '&gt;')
118
+ .gsub('"', '&quot;')
119
+ .gsub("'", '&#x27;')
120
120
  end
121
121
 
122
122
  def otto_styles
@@ -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
@@ -18,7 +22,7 @@ class Otto
18
22
  forwarded_ips = [
19
23
  env['HTTP_X_FORWARDED_FOR'],
20
24
  env['HTTP_X_REAL_IP'],
21
- env['HTTP_CLIENT_IP']
25
+ env['HTTP_CLIENT_IP'],
22
26
  ].compact.map { |header| header.split(/,\s*/) }.flatten
23
27
 
24
28
  # Return the first valid IP that's not a private/loopback address
@@ -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,16 +115,12 @@ 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)
115
121
  otto.security_config
116
122
  elsif defined?(Otto) && Otto.respond_to?(:security_config)
117
123
  Otto.security_config
118
- else
119
- nil
120
124
  end
121
125
  end
122
126
 
@@ -138,7 +142,7 @@ class Otto
138
142
 
139
143
  # Validate each octet
140
144
  octets = clean_ip.split('.')
141
- 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) }
142
146
 
143
147
  clean_ip
144
148
  end
@@ -153,7 +157,7 @@ class Otto
153
157
  /\A192\.168\./, # 192.168.0.0/16
154
158
  /\A169\.254\./, # 169.254.0.0/16 (link-local)
155
159
  /\A224\./, # 224.0.0.0/4 (multicast)
156
- /\A0\./ # 0.0.0.0/8
160
+ /\A0\./, # 0.0.0.0/8
157
161
  ]
158
162
 
159
163
  private_ranges.any? { |range| ip.match?(range) }
@@ -163,10 +167,223 @@ class Otto
163
167
  return false unless ip
164
168
 
165
169
  # Check for localhost
166
- return true if ip == '127.0.0.1' || ip == '::1'
170
+ return true if ['127.0.0.1', '::1'].include?(ip)
167
171
 
168
172
  # Check for private IP ranges
169
173
  private_ip?(ip)
170
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
171
388
  end
172
389
  end
@@ -1,76 +1,54 @@
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 = {})
8
- send_cookie name, value, ttl, opts.merge(secure: true)
9
- end
10
-
11
- def send_cookie(name, value, ttl, opts = {})
12
12
  # Default security options
13
13
  defaults = {
14
14
  secure: true,
15
15
  httponly: true,
16
- samesite: :lax,
17
- path: '/'
16
+ same_site: :strict,
17
+ path: '/',
18
18
  }
19
19
 
20
20
  # Merge with provided options
21
21
  cookie_opts = defaults.merge(opts)
22
22
 
23
- # Adjust secure flag for local development
24
- if request.local?
25
- cookie_opts[:secure] = false
26
- end
27
-
28
23
  # Set expiration using max-age (preferred) and expires (fallback)
29
- if ttl && ttl > 0
24
+ if ttl&.positive?
30
25
  cookie_opts[:max_age] = ttl
31
26
  cookie_opts[:expires] = (Time.now.utc + ttl + 10)
32
- elsif ttl && ttl < 0
27
+ elsif ttl&.negative?
33
28
  # For deletion, set both to past date
34
29
  cookie_opts[:max_age] = 0
35
- cookie_opts[:expires] = Time.now.utc - 86400
30
+ cookie_opts[:expires] = Time.now.utc - 86_400
36
31
  end
37
32
 
38
33
  # Set the cookie value
39
34
  cookie_opts[:value] = value
40
35
 
41
36
  # 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
37
+ valid_same_site = [:strict, :lax, :none, 'Strict', 'Lax', 'None']
38
+ cookie_opts[:same_site] = :strict unless valid_same_site.include?(cookie_opts[:same_site])
46
39
 
47
40
  # If SameSite=None, Secure must be true
48
- if cookie_opts[:samesite].to_s.downcase == 'none'
49
- cookie_opts[:secure] = true
50
- end
41
+ cookie_opts[:secure] = true if cookie_opts[:same_site].to_s.downcase == 'none'
51
42
 
52
43
  set_cookie name, cookie_opts
53
44
  end
54
45
 
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
46
  def send_session_cookie(name, value, opts = {})
69
47
  # Session cookies don't have expiration
70
48
  session_opts = opts.merge(
71
49
  secure: true,
72
50
  httponly: true,
73
- samesite: :lax
51
+ samesite: :strict,
74
52
  )
75
53
 
76
54
  # Remove expiration-related options for session cookies
@@ -78,9 +56,7 @@ class Otto
78
56
  session_opts.delete(:expires)
79
57
 
80
58
  # Adjust secure flag for local development
81
- if request.local?
82
- session_opts[:secure] = false
83
- end
59
+ session_opts[:secure] = false if request.local?
84
60
 
85
61
  session_opts[:value] = value
86
62
  set_cookie name, session_opts
@@ -104,5 +80,76 @@ class Otto
104
80
 
105
81
  headers
106
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
107
154
  end
108
155
  end