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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +1 -3
- data/Gemfile.lock +5 -3
- data/README.md +58 -2
- data/docs/.gitignore +2 -0
- 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/response_handlers.rb +141 -0
- data/lib/otto/route.rb +125 -54
- data/lib/otto/route_definition.rb +187 -0
- data/lib/otto/route_handlers.rb +383 -0
- data/lib/otto/security/authentication.rb +289 -0
- data/lib/otto/security/config.rb +99 -1
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +143 -3
- data/otto.gemspec +2 -2
- metadata +29 -2
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
|
|
@@ -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
|
-
|
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
|
98
|
+
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
|
75
99
|
raw.each do |entry|
|
76
|
-
|
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.
|
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.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.
|
134
|
+
version: '3.2'
|
108
135
|
- - "<"
|
109
136
|
- !ruby/object:Gem::Version
|
110
137
|
version: '4.0'
|