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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
- VERSION = '1.3.0'.freeze
4
+ VERSION = '1.5.0'.freeze
5
5
  end
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
 
@@ -7,14 +8,18 @@ require 'rack/request'
7
8
  require 'rack/response'
8
9
  require 'rack/utils'
9
10
 
11
+ require_relative 'otto/route_definition'
10
12
  require_relative 'otto/route'
11
13
  require_relative 'otto/static'
12
14
  require_relative 'otto/helpers/request'
13
15
  require_relative 'otto/helpers/response'
16
+ require_relative 'otto/response_handlers'
17
+ require_relative 'otto/route_handlers'
14
18
  require_relative 'otto/version'
15
19
  require_relative 'otto/security/config'
16
20
  require_relative 'otto/security/csrf'
17
21
  require_relative 'otto/security/validator'
22
+ require_relative 'otto/security/authentication'
18
23
 
19
24
  # Otto is a simple Rack router that allows you to define routes in a file
20
25
  # with built-in security features including CSRF protection, input validation,
@@ -42,8 +47,20 @@ class Otto
42
47
 
43
48
  @debug = ENV['OTTO_DEBUG'] == 'true'
44
49
  @logger = Logger.new($stdout, Logger::INFO)
50
+ @global_config = {}
45
51
 
46
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config
52
+ # Global configuration for all Otto instances
53
+ def self.configure
54
+ config = OpenStruct.new(@global_config)
55
+ yield config
56
+ @global_config = config.to_h
57
+ end
58
+
59
+ def self.global_config
60
+ @global_config
61
+ end
62
+
63
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory
47
64
  attr_accessor :not_found, :server_error, :middleware_stack
48
65
 
49
66
  def initialize(path = nil, opts = {})
@@ -57,10 +74,17 @@ class Otto
57
74
  }.merge(opts)
58
75
  @security_config = Otto::Security::Config.new
59
76
  @middleware_stack = []
77
+ @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
78
+
79
+ # Configure locale support (merge global config with instance options)
80
+ configure_locale(opts)
60
81
 
61
82
  # Configure security based on options
62
83
  configure_security(opts)
63
84
 
85
+ # Configure authentication based on options
86
+ configure_authentication(opts)
87
+
64
88
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
65
89
  load(path) unless path.nil?
66
90
  super()
@@ -71,9 +95,12 @@ class Otto
71
95
  path = File.expand_path(path)
72
96
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
73
97
 
74
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
98
+ raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
75
99
  raw.each do |entry|
76
- verb, path, definition = *entry
100
+ # Enhanced parsing: split only on first two whitespace boundaries
101
+ # This preserves parameters in the definition part
102
+ parts = entry.split(/\s+/, 3)
103
+ verb, path, definition = parts[0], parts[1], parts[2]
77
104
  route = Otto::Route.new verb, path, definition
78
105
  route.otto = self
79
106
  path_clean = path.gsub(%r{/$}, '')
@@ -157,6 +184,7 @@ class Otto
157
184
  def handle_request(env)
158
185
  locale = determine_locale env
159
186
  env['rack.locale'] = locale
187
+ env['otto.locale_config'] = @locale_config if @locale_config
160
188
  @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
161
189
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
162
190
  path_info = '/' if path_info.to_s.empty?
@@ -370,8 +398,107 @@ class Otto
370
398
  @security_config.enable_frame_protection!(option)
371
399
  end
372
400
 
401
+ # Enable Content Security Policy (CSP) with nonce support for dynamic header generation.
402
+ # This enables the res.send_csp_headers response helper method.
403
+ #
404
+ # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
405
+ # @example
406
+ # otto.enable_csp_with_nonce!(debug: true)
407
+ def enable_csp_with_nonce!(debug: false)
408
+ @security_config.enable_csp_with_nonce!(debug: debug)
409
+ end
410
+
411
+ # Configure locale settings for the application
412
+ #
413
+ # @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
414
+ # @param default_locale [String] Default locale to use as fallback
415
+ # @example
416
+ # otto.configure(
417
+ # available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
418
+ # default_locale: 'en'
419
+ # )
420
+ def configure(available_locales: nil, default_locale: nil)
421
+ @locale_config ||= {}
422
+ @locale_config[:available_locales] = available_locales if available_locales
423
+ @locale_config[:default_locale] = default_locale if default_locale
424
+ end
425
+
426
+ # Enable authentication middleware for route-level access control.
427
+ # This will automatically check route auth parameters and enforce authentication.
428
+ #
429
+ # @example
430
+ # otto.enable_authentication!
431
+ def enable_authentication!
432
+ return if middleware_enabled?(Otto::Security::AuthenticationMiddleware)
433
+
434
+ use Otto::Security::AuthenticationMiddleware, @auth_config
435
+ end
436
+
437
+ # Configure authentication strategies for route-level access control.
438
+ #
439
+ # @param strategies [Hash] Hash mapping strategy names to strategy instances
440
+ # @param default_strategy [String] Default strategy to use when none specified
441
+ # @example
442
+ # otto.configure_auth_strategies({
443
+ # 'publically' => Otto::Security::PublicStrategy.new,
444
+ # 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
445
+ # 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
446
+ # 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
447
+ # })
448
+ def configure_auth_strategies(strategies, default_strategy: 'publically')
449
+ @auth_config ||= {}
450
+ @auth_config[:auth_strategies] = strategies
451
+ @auth_config[:default_auth_strategy] = default_strategy
452
+
453
+ enable_authentication! unless strategies.empty?
454
+ end
455
+
456
+ # Add a single authentication strategy
457
+ #
458
+ # @param name [String] Strategy name
459
+ # @param strategy [Otto::Security::AuthStrategy] Strategy instance
460
+ # @example
461
+ # otto.add_auth_strategy('custom', MyCustomStrategy.new)
462
+ def add_auth_strategy(name, strategy)
463
+ @auth_config ||= { auth_strategies: {}, default_auth_strategy: 'publically' }
464
+ @auth_config[:auth_strategies][name] = strategy
465
+
466
+ enable_authentication!
467
+ end
468
+
373
469
  private
374
470
 
471
+ def configure_locale(opts)
472
+ # Start with global configuration
473
+ global_config = self.class.global_config
474
+ @locale_config = nil
475
+
476
+ # Check if we have any locale configuration from any source
477
+ has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
478
+ has_direct_options = opts[:available_locales] || opts[:default_locale]
479
+ has_legacy_config = opts[:locale_config]
480
+
481
+ # Only create locale_config if we have configuration from somewhere
482
+ if has_global_locale || has_direct_options || has_legacy_config
483
+ @locale_config = {}
484
+
485
+ # Apply global configuration first
486
+ @locale_config[:available_locales] = global_config[:available_locales] if global_config && global_config[:available_locales]
487
+ @locale_config[:default_locale] = global_config[:default_locale] if global_config && global_config[:default_locale]
488
+
489
+ # Apply direct instance options (these override global config)
490
+ @locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
491
+ @locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
492
+
493
+ # Legacy support: Configure locale if provided in initialization options via locale_config hash
494
+ if opts[:locale_config]
495
+ locale_opts = opts[:locale_config]
496
+ @locale_config[:available_locales] = locale_opts[:available_locales] || locale_opts[:available] if locale_opts[:available_locales] || locale_opts[:available]
497
+ @locale_config[:default_locale] = locale_opts[:default_locale] || locale_opts[:default] if locale_opts[:default_locale] || locale_opts[:default]
498
+ end
499
+ end
500
+ end
501
+
375
502
  def configure_security(opts)
376
503
  # Enable CSRF protection if requested
377
504
  enable_csrf_protection! if opts[:csrf_protection]
@@ -394,6 +521,19 @@ class Otto
394
521
  @middleware_stack.any? { |m| m == middleware_class }
395
522
  end
396
523
 
524
+ def configure_authentication(opts)
525
+ # Configure authentication strategies
526
+ @auth_config = {
527
+ auth_strategies: opts[:auth_strategies] || {},
528
+ default_auth_strategy: opts[:default_auth_strategy] || 'publically'
529
+ }
530
+
531
+ # Enable authentication middleware if strategies are configured
532
+ if opts[:auth_strategies] && !opts[:auth_strategies].empty?
533
+ enable_authentication!
534
+ end
535
+ end
536
+
397
537
  def handle_error(error, env)
398
538
  # Log error details internally but don't expose them
399
539
  error_id = SecureRandom.hex(8)
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.4', '< 4.0']
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.3.0
4
+ version: 1.5.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,27 +77,40 @@ 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
70
88
  - Gemfile.lock
71
89
  - LICENSE.txt
72
90
  - README.md
91
+ - docs/.gitignore
73
92
  - examples/basic/app.rb
74
93
  - examples/basic/config.ru
75
94
  - examples/basic/routes
76
95
  - examples/dynamic_pages/app.rb
77
96
  - examples/dynamic_pages/config.ru
78
97
  - examples/dynamic_pages/routes
98
+ - examples/helpers_demo/app.rb
99
+ - examples/helpers_demo/config.ru
100
+ - examples/helpers_demo/routes
79
101
  - examples/security_features/app.rb
80
102
  - examples/security_features/config.ru
81
103
  - examples/security_features/routes
82
104
  - lib/otto.rb
83
105
  - lib/otto/design_system.rb
106
+ - lib/otto/helpers/base.rb
84
107
  - lib/otto/helpers/request.rb
85
108
  - lib/otto/helpers/response.rb
109
+ - lib/otto/response_handlers.rb
86
110
  - lib/otto/route.rb
111
+ - lib/otto/route_definition.rb
112
+ - lib/otto/route_handlers.rb
113
+ - lib/otto/security/authentication.rb
87
114
  - lib/otto/security/config.rb
88
115
  - lib/otto/security/csrf.rb
89
116
  - lib/otto/security/validator.rb
@@ -104,7 +131,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
104
131
  requirements:
105
132
  - - ">="
106
133
  - !ruby/object:Gem::Version
107
- version: '3.4'
134
+ version: '3.2'
108
135
  - - "<"
109
136
  - !ruby/object:Gem::Version
110
137
  version: '4.0'