otto 1.3.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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +0 -2
- data/Gemfile.lock +3 -1
- data/README.md +58 -2
- data/examples/helpers_demo/app.rb +244 -0
- data/examples/helpers_demo/config.ru +26 -0
- data/examples/helpers_demo/routes +7 -0
- data/lib/otto/helpers/base.rb +27 -0
- data/lib/otto/helpers/request.rb +223 -4
- data/lib/otto/helpers/response.rb +75 -0
- data/lib/otto/route.rb +5 -0
- data/lib/otto/security/config.rb +99 -1
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +74 -1
- data/otto.gemspec +2 -2
- metadata +24 -2
data/lib/otto/helpers/request.rb
CHANGED
@@ -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
|
-
|
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).
|
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
|
data/lib/otto/route.rb
CHANGED
@@ -115,6 +115,11 @@ class Otto
|
|
115
115
|
res.extend Otto::ResponseHelpers
|
116
116
|
res.request = req
|
117
117
|
|
118
|
+
# Make security config available to response helpers
|
119
|
+
if otto.respond_to?(:security_config) && otto.security_config
|
120
|
+
env['otto.security_config'] = otto.security_config
|
121
|
+
end
|
122
|
+
|
118
123
|
# Process parameters through security layer
|
119
124
|
req.params.merge! extra_params
|
120
125
|
req.params.replace Otto::Static.indifferent_params(req.params)
|
data/lib/otto/security/config.rb
CHANGED
@@ -24,7 +24,8 @@ class Otto
|
|
24
24
|
attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
|
25
25
|
:max_request_size, :max_param_depth, :max_param_keys,
|
26
26
|
:trusted_proxies, :require_secure_cookies,
|
27
|
-
:security_headers, :input_validation
|
27
|
+
:security_headers, :input_validation,
|
28
|
+
:csp_nonce_enabled, :debug_csp
|
28
29
|
|
29
30
|
# Initialize security configuration with safe defaults
|
30
31
|
#
|
@@ -42,6 +43,8 @@ class Otto
|
|
42
43
|
@require_secure_cookies = false
|
43
44
|
@security_headers = default_security_headers
|
44
45
|
@input_validation = true
|
46
|
+
@csp_nonce_enabled = false
|
47
|
+
@debug_csp = false
|
45
48
|
end
|
46
49
|
|
47
50
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
@@ -199,6 +202,53 @@ class Otto
|
|
199
202
|
@security_headers['content-security-policy'] = policy
|
200
203
|
end
|
201
204
|
|
205
|
+
# Enable Content Security Policy (CSP) with nonce support
|
206
|
+
#
|
207
|
+
# This enables dynamic CSP header generation with nonces for enhanced security.
|
208
|
+
# Unlike enable_csp!, this doesn't set a static policy but enables the response
|
209
|
+
# helper to generate CSP headers with nonces on a per-request basis.
|
210
|
+
#
|
211
|
+
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
212
|
+
# @return [void]
|
213
|
+
#
|
214
|
+
# @example
|
215
|
+
# config.enable_csp_with_nonce!(debug: true)
|
216
|
+
def enable_csp_with_nonce!(debug: false)
|
217
|
+
@csp_nonce_enabled = true
|
218
|
+
@debug_csp = debug
|
219
|
+
end
|
220
|
+
|
221
|
+
# Disable CSP nonce support
|
222
|
+
#
|
223
|
+
# @return [void]
|
224
|
+
def disable_csp_nonce!
|
225
|
+
@csp_nonce_enabled = false
|
226
|
+
end
|
227
|
+
|
228
|
+
# Check if CSP nonce support is enabled
|
229
|
+
#
|
230
|
+
# @return [Boolean] true if CSP nonce support is enabled
|
231
|
+
def csp_nonce_enabled?
|
232
|
+
@csp_nonce_enabled
|
233
|
+
end
|
234
|
+
|
235
|
+
# Check if CSP debug logging is enabled
|
236
|
+
#
|
237
|
+
# @return [Boolean] true if CSP debug logging is enabled
|
238
|
+
def debug_csp?
|
239
|
+
@debug_csp
|
240
|
+
end
|
241
|
+
|
242
|
+
# Generate a CSP policy string with the provided nonce
|
243
|
+
#
|
244
|
+
# @param nonce [String] The nonce value to include in the CSP
|
245
|
+
# @param development_mode [Boolean] Whether to use development-friendly directives
|
246
|
+
# @return [String] Complete CSP policy string
|
247
|
+
def generate_nonce_csp(nonce, development_mode: false)
|
248
|
+
directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
|
249
|
+
directives.join(' ')
|
250
|
+
end
|
251
|
+
|
202
252
|
# Enable X-Frame-Options header to prevent clickjacking
|
203
253
|
#
|
204
254
|
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
@@ -298,6 +348,54 @@ class Otto
|
|
298
348
|
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
299
349
|
result == 0
|
300
350
|
end
|
351
|
+
|
352
|
+
# Generate CSP directives for development environment
|
353
|
+
#
|
354
|
+
# Development mode allows inline scripts/styles and hot reloading connections
|
355
|
+
# for better developer experience with build tools like Vite.
|
356
|
+
#
|
357
|
+
# @param nonce [String] The nonce value to include in script-src
|
358
|
+
# @return [Array<String>] Array of CSP directive strings
|
359
|
+
def development_csp_directives(nonce)
|
360
|
+
[
|
361
|
+
"default-src 'none';",
|
362
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
363
|
+
"style-src 'self' 'unsafe-inline';",
|
364
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
365
|
+
"img-src 'self' data:;",
|
366
|
+
"font-src 'self';",
|
367
|
+
"object-src 'none';",
|
368
|
+
"base-uri 'self';",
|
369
|
+
"form-action 'self';",
|
370
|
+
"frame-ancestors 'none';",
|
371
|
+
"manifest-src 'self';",
|
372
|
+
"worker-src 'self' data:;",
|
373
|
+
]
|
374
|
+
end
|
375
|
+
|
376
|
+
# Generate CSP directives for production environment
|
377
|
+
#
|
378
|
+
# Production mode is more restrictive, only allowing HTTPS connections
|
379
|
+
# and nonce-only scripts for enhanced XSS protection.
|
380
|
+
#
|
381
|
+
# @param nonce [String] The nonce value to include in script-src
|
382
|
+
# @return [Array<String>] Array of CSP directive strings
|
383
|
+
def production_csp_directives(nonce)
|
384
|
+
[
|
385
|
+
"default-src 'none';", # Restrict to same origin by default
|
386
|
+
"script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
|
387
|
+
"style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
|
388
|
+
"connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
|
389
|
+
"img-src 'self' data:;", # Allow images from same origin and data URIs
|
390
|
+
"font-src 'self';", # Allow fonts from same origin only
|
391
|
+
"object-src 'none';", # Block <object>, <embed>, and <applet> elements
|
392
|
+
"base-uri 'self';", # Restrict <base> tag targets to same origin
|
393
|
+
"form-action 'self';", # Restrict form submissions to same origin
|
394
|
+
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
395
|
+
"manifest-src 'self';", # Allow web app manifests from same origin
|
396
|
+
"worker-src 'self' data:;", # Allow Workers from same origin and data blobs
|
397
|
+
]
|
398
|
+
end
|
301
399
|
end
|
302
400
|
|
303
401
|
# Raised when a request exceeds the configured size limit
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'json'
|
2
2
|
require 'logger'
|
3
|
+
require 'ostruct'
|
3
4
|
require 'securerandom'
|
4
5
|
require 'uri'
|
5
6
|
|
@@ -42,8 +43,20 @@ class Otto
|
|
42
43
|
|
43
44
|
@debug = ENV['OTTO_DEBUG'] == 'true'
|
44
45
|
@logger = Logger.new($stdout, Logger::INFO)
|
46
|
+
@global_config = {}
|
45
47
|
|
46
|
-
|
48
|
+
# Global configuration for all Otto instances
|
49
|
+
def self.configure
|
50
|
+
config = OpenStruct.new(@global_config)
|
51
|
+
yield config
|
52
|
+
@global_config = config.to_h
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.global_config
|
56
|
+
@global_config
|
57
|
+
end
|
58
|
+
|
59
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config
|
47
60
|
attr_accessor :not_found, :server_error, :middleware_stack
|
48
61
|
|
49
62
|
def initialize(path = nil, opts = {})
|
@@ -58,6 +71,9 @@ class Otto
|
|
58
71
|
@security_config = Otto::Security::Config.new
|
59
72
|
@middleware_stack = []
|
60
73
|
|
74
|
+
# Configure locale support (merge global config with instance options)
|
75
|
+
configure_locale(opts)
|
76
|
+
|
61
77
|
# Configure security based on options
|
62
78
|
configure_security(opts)
|
63
79
|
|
@@ -157,6 +173,7 @@ class Otto
|
|
157
173
|
def handle_request(env)
|
158
174
|
locale = determine_locale env
|
159
175
|
env['rack.locale'] = locale
|
176
|
+
env['otto.locale_config'] = @locale_config if @locale_config
|
160
177
|
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
161
178
|
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
162
179
|
path_info = '/' if path_info.to_s.empty?
|
@@ -370,8 +387,64 @@ class Otto
|
|
370
387
|
@security_config.enable_frame_protection!(option)
|
371
388
|
end
|
372
389
|
|
390
|
+
# Enable Content Security Policy (CSP) with nonce support for dynamic header generation.
|
391
|
+
# This enables the res.send_csp_headers response helper method.
|
392
|
+
#
|
393
|
+
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
394
|
+
# @example
|
395
|
+
# otto.enable_csp_with_nonce!(debug: true)
|
396
|
+
def enable_csp_with_nonce!(debug: false)
|
397
|
+
@security_config.enable_csp_with_nonce!(debug: debug)
|
398
|
+
end
|
399
|
+
|
400
|
+
# Configure locale settings for the application
|
401
|
+
#
|
402
|
+
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
403
|
+
# @param default_locale [String] Default locale to use as fallback
|
404
|
+
# @example
|
405
|
+
# otto.configure(
|
406
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
|
407
|
+
# default_locale: 'en'
|
408
|
+
# )
|
409
|
+
def configure(available_locales: nil, default_locale: nil)
|
410
|
+
@locale_config ||= {}
|
411
|
+
@locale_config[:available_locales] = available_locales if available_locales
|
412
|
+
@locale_config[:default_locale] = default_locale if default_locale
|
413
|
+
end
|
414
|
+
|
373
415
|
private
|
374
416
|
|
417
|
+
def configure_locale(opts)
|
418
|
+
# Start with global configuration
|
419
|
+
global_config = self.class.global_config
|
420
|
+
@locale_config = nil
|
421
|
+
|
422
|
+
# Check if we have any locale configuration from any source
|
423
|
+
has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
|
424
|
+
has_direct_options = opts[:available_locales] || opts[:default_locale]
|
425
|
+
has_legacy_config = opts[:locale_config]
|
426
|
+
|
427
|
+
# Only create locale_config if we have configuration from somewhere
|
428
|
+
if has_global_locale || has_direct_options || has_legacy_config
|
429
|
+
@locale_config = {}
|
430
|
+
|
431
|
+
# Apply global configuration first
|
432
|
+
@locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
|
433
|
+
@locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
|
434
|
+
|
435
|
+
# Apply direct instance options (these override global config)
|
436
|
+
@locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
|
437
|
+
@locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
|
438
|
+
|
439
|
+
# Legacy support: Configure locale if provided in initialization options via locale_config hash
|
440
|
+
if opts[:locale_config]
|
441
|
+
locale_opts = opts[:locale_config]
|
442
|
+
@locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
|
443
|
+
@locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
|
444
|
+
end
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
375
448
|
def configure_security(opts)
|
376
449
|
# Enable CSRF protection if requested
|
377
450
|
enable_csrf_protection! if opts[:csrf_protection]
|
data/otto.gemspec
CHANGED
@@ -16,11 +16,11 @@ Gem::Specification.new do |spec|
|
|
16
16
|
spec.homepage = 'https://github.com/delano/otto'
|
17
17
|
spec.require_paths = ['lib']
|
18
18
|
|
19
|
-
spec.required_ruby_version = ['>= 3.
|
19
|
+
spec.required_ruby_version = ['>= 3.2', '< 4.0']
|
20
20
|
|
21
21
|
# https://github.com/delano/otto/security/dependabot/5
|
22
22
|
spec.add_dependency 'rexml', '>= 3.3.6'
|
23
|
-
|
23
|
+
spec.add_dependency 'ostruct'
|
24
24
|
spec.add_dependency 'rack', '~> 3.1', '< 4.0'
|
25
25
|
spec.add_dependency 'rack-parser', '~> 0.7'
|
26
26
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: otto
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Delano Mandelbaum
|
@@ -23,6 +23,20 @@ dependencies:
|
|
23
23
|
- - ">="
|
24
24
|
- !ruby/object:Gem::Version
|
25
25
|
version: 3.3.6
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: ostruct
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
26
40
|
- !ruby/object:Gem::Dependency
|
27
41
|
name: rack
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
@@ -63,7 +77,11 @@ executables: []
|
|
63
77
|
extensions: []
|
64
78
|
extra_rdoc_files: []
|
65
79
|
files:
|
80
|
+
- ".github/dependabot.yml"
|
81
|
+
- ".github/workflows/ci.yml"
|
66
82
|
- ".gitignore"
|
83
|
+
- ".pre-commit-config.yaml"
|
84
|
+
- ".pre-push-config.yaml"
|
67
85
|
- ".rspec"
|
68
86
|
- ".rubocop.yml"
|
69
87
|
- Gemfile
|
@@ -76,11 +94,15 @@ files:
|
|
76
94
|
- examples/dynamic_pages/app.rb
|
77
95
|
- examples/dynamic_pages/config.ru
|
78
96
|
- examples/dynamic_pages/routes
|
97
|
+
- examples/helpers_demo/app.rb
|
98
|
+
- examples/helpers_demo/config.ru
|
99
|
+
- examples/helpers_demo/routes
|
79
100
|
- examples/security_features/app.rb
|
80
101
|
- examples/security_features/config.ru
|
81
102
|
- examples/security_features/routes
|
82
103
|
- lib/otto.rb
|
83
104
|
- lib/otto/design_system.rb
|
105
|
+
- lib/otto/helpers/base.rb
|
84
106
|
- lib/otto/helpers/request.rb
|
85
107
|
- lib/otto/helpers/response.rb
|
86
108
|
- lib/otto/route.rb
|
@@ -104,7 +126,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
104
126
|
requirements:
|
105
127
|
- - ">="
|
106
128
|
- !ruby/object:Gem::Version
|
107
|
-
version: '3.
|
129
|
+
version: '3.2'
|
108
130
|
- - "<"
|
109
131
|
- !ruby/object:Gem::Version
|
110
132
|
version: '4.0'
|