otto 2.0.0.pre2 → 2.0.0.pre3
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/workflows/ci.yml +0 -2
- data/.github/workflows/claude-code-review.yml +29 -13
- data/CLAUDE.md +537 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +17 -10
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +89 -39
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +24 -17
- data/lib/otto/core/router.rb +1 -1
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +8 -4
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +3 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +16 -14
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
- data/lib/otto/security/authentication.rb +3 -4
- data/lib/otto/security/config.rb +51 -7
- data/lib/otto/security/configurator.rb +0 -13
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +181 -86
- data/otto.gemspec +3 -0
- metadata +58 -3
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -25,22 +25,41 @@ class Otto
|
|
|
25
25
|
# wrapped.call(env, extra_params)
|
|
26
26
|
#
|
|
27
27
|
class RouteAuthWrapper
|
|
28
|
-
attr_reader :wrapped_handler, :route_definition, :auth_config
|
|
28
|
+
attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
|
|
29
29
|
|
|
30
|
-
def initialize(wrapped_handler, route_definition, auth_config)
|
|
30
|
+
def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
|
|
31
31
|
@wrapped_handler = wrapped_handler
|
|
32
32
|
@route_definition = route_definition
|
|
33
33
|
@auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
|
34
|
+
@security_config = security_config
|
|
35
|
+
@strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
# Execute authentication then call wrapped handler
|
|
37
39
|
#
|
|
40
|
+
# For routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
41
|
+
# For routes WITH auth requirement: Enforces authentication
|
|
42
|
+
#
|
|
38
43
|
# @param env [Hash] Rack environment
|
|
39
44
|
# @param extra_params [Hash] Additional parameters
|
|
40
45
|
# @return [Array] Rack response array
|
|
41
46
|
def call(env, extra_params = {})
|
|
42
|
-
# Execute authentication strategy for this route
|
|
43
47
|
auth_requirement = route_definition.auth_requirement
|
|
48
|
+
|
|
49
|
+
# Routes without auth requirement get anonymous StrategyResult
|
|
50
|
+
unless auth_requirement
|
|
51
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
52
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
53
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
54
|
+
|
|
55
|
+
result = StrategyResult.anonymous(metadata: metadata)
|
|
56
|
+
env['otto.strategy_result'] = result
|
|
57
|
+
env['otto.user'] = nil
|
|
58
|
+
env['otto.user_context'] = result.user_context
|
|
59
|
+
return wrapped_handler.call(env, extra_params)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Routes WITH auth requirement: Execute authentication strategy
|
|
44
63
|
strategy = get_strategy(auth_requirement)
|
|
45
64
|
|
|
46
65
|
unless strategy
|
|
@@ -51,36 +70,107 @@ class Otto
|
|
|
51
70
|
# Execute the strategy
|
|
52
71
|
result = strategy.authenticate(env, auth_requirement)
|
|
53
72
|
|
|
54
|
-
# Set environment variables for controllers/logic
|
|
55
|
-
env['otto.strategy_result'] = result
|
|
56
|
-
env['otto.user'] = result.user if result.is_a?(StrategyResult)
|
|
57
|
-
env['otto.user_context'] = result.user_context if result.is_a?(StrategyResult)
|
|
58
|
-
|
|
59
73
|
# Handle authentication failure
|
|
60
|
-
if result.is_a?(
|
|
74
|
+
if result.is_a?(AuthFailure)
|
|
75
|
+
# Create anonymous result with failure info for logging/auditing
|
|
76
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
77
|
+
metadata = {
|
|
78
|
+
ip: env['REMOTE_ADDR'],
|
|
79
|
+
auth_failure: result.failure_reason,
|
|
80
|
+
attempted_strategy: auth_requirement
|
|
81
|
+
}
|
|
82
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
83
|
+
|
|
84
|
+
env['otto.strategy_result'] = StrategyResult.anonymous(metadata: metadata)
|
|
85
|
+
env['otto.user'] = nil
|
|
86
|
+
env['otto.user_context'] = {}
|
|
61
87
|
return auth_failure_response(env, result)
|
|
62
88
|
end
|
|
63
89
|
|
|
90
|
+
# Set environment variables for controllers/logic on success
|
|
91
|
+
env['otto.strategy_result'] = result
|
|
92
|
+
env['otto.user'] = result.user
|
|
93
|
+
env['otto.user_context'] = result.user_context
|
|
94
|
+
|
|
95
|
+
# SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
|
|
96
|
+
# We must ensure env['rack.session'] and strategy_result.session reference
|
|
97
|
+
# the SAME object so that:
|
|
98
|
+
# 1. Logic classes write to strategy_result.session
|
|
99
|
+
# 2. Rack's session middleware persists env['rack.session']
|
|
100
|
+
# 3. Changes from (1) are included in (2)
|
|
101
|
+
#
|
|
102
|
+
# Using merge! instead would break this - the objects must be identical.
|
|
103
|
+
env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
|
|
104
|
+
|
|
64
105
|
# Authentication succeeded - call wrapped handler
|
|
65
106
|
wrapped_handler.call(env, extra_params)
|
|
66
107
|
end
|
|
67
108
|
|
|
68
109
|
private
|
|
69
110
|
|
|
70
|
-
# Get strategy from auth_config hash
|
|
111
|
+
# Get strategy from auth_config hash with sophisticated pattern matching
|
|
112
|
+
#
|
|
113
|
+
# Supports:
|
|
114
|
+
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
115
|
+
# - Prefix match: 'role:admin' → looks up 'role' strategy
|
|
116
|
+
# - Fallback: 'role:*' → creates default RoleStrategy
|
|
117
|
+
# - Fallback: 'permission:*' → creates default PermissionStrategy
|
|
118
|
+
#
|
|
119
|
+
# Results are cached to avoid repeated lookups for the same requirement.
|
|
71
120
|
#
|
|
72
121
|
# @param requirement [String] Auth requirement from route
|
|
73
122
|
# @return [AuthStrategy, nil] Strategy instance or nil
|
|
74
123
|
def get_strategy(requirement)
|
|
75
124
|
return nil unless auth_config && auth_config[:auth_strategies]
|
|
76
125
|
|
|
77
|
-
|
|
126
|
+
# Check cache first
|
|
127
|
+
return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
|
|
128
|
+
|
|
129
|
+
# Try exact match first - this has highest priority
|
|
130
|
+
strategy = auth_config[:auth_strategies][requirement]
|
|
131
|
+
if strategy
|
|
132
|
+
@strategy_cache[requirement] = strategy
|
|
133
|
+
return strategy
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# For colon-separated requirements like "role:admin", try prefix match
|
|
137
|
+
if requirement.include?(':')
|
|
138
|
+
prefix = requirement.split(':', 2).first
|
|
139
|
+
|
|
140
|
+
# Check if we have a strategy registered for the prefix
|
|
141
|
+
prefix_strategy = auth_config[:auth_strategies][prefix]
|
|
142
|
+
if prefix_strategy
|
|
143
|
+
@strategy_cache[requirement] = prefix_strategy
|
|
144
|
+
return prefix_strategy
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Try fallback patterns for role: and permission: requirements
|
|
148
|
+
if requirement.start_with?('role:')
|
|
149
|
+
# Cache the fallback strategy under 'role:' key to create it only once
|
|
150
|
+
strategy = @strategy_cache['role:'] ||= begin
|
|
151
|
+
auth_config[:auth_strategies]['role'] || Strategies::RoleStrategy.new([])
|
|
152
|
+
end
|
|
153
|
+
@strategy_cache[requirement] = strategy
|
|
154
|
+
return strategy
|
|
155
|
+
elsif requirement.start_with?('permission:')
|
|
156
|
+
# Cache the fallback strategy under 'permission:' key
|
|
157
|
+
strategy = @strategy_cache['permission:'] ||= begin
|
|
158
|
+
auth_config[:auth_strategies]['permission'] || Strategies::PermissionStrategy.new([])
|
|
159
|
+
end
|
|
160
|
+
@strategy_cache[requirement] = strategy
|
|
161
|
+
return strategy
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Cache nil results too to avoid repeated failed lookups
|
|
166
|
+
@strategy_cache[requirement] = nil
|
|
167
|
+
nil
|
|
78
168
|
end
|
|
79
169
|
|
|
80
170
|
# Generate 401 response for authentication failure
|
|
81
171
|
#
|
|
82
172
|
# @param env [Hash] Rack environment
|
|
83
|
-
# @param result [
|
|
173
|
+
# @param result [AuthFailure] Failure result from strategy
|
|
84
174
|
# @return [Array] Rack response array
|
|
85
175
|
def auth_failure_response(env, result)
|
|
86
176
|
# Check if request wants JSON
|
|
@@ -96,7 +186,7 @@ class Otto
|
|
|
96
186
|
|
|
97
187
|
# Generate JSON 401 response
|
|
98
188
|
#
|
|
99
|
-
# @param result [
|
|
189
|
+
# @param result [AuthFailure] Failure result
|
|
100
190
|
# @return [Array] Rack response array
|
|
101
191
|
def json_auth_error(result)
|
|
102
192
|
body = {
|
|
@@ -105,26 +195,31 @@ class Otto
|
|
|
105
195
|
timestamp: Time.now.to_i
|
|
106
196
|
}.to_json
|
|
107
197
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
198
|
+
headers = {
|
|
199
|
+
'content-type' => 'application/json',
|
|
200
|
+
'content-length' => body.bytesize.to_s
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Add security headers if available
|
|
204
|
+
merge_security_headers!(headers)
|
|
205
|
+
|
|
206
|
+
[401, headers, [body]]
|
|
113
207
|
end
|
|
114
208
|
|
|
115
209
|
# Generate HTML 401 response or redirect
|
|
116
210
|
#
|
|
117
|
-
# @param result [
|
|
211
|
+
# @param result [AuthFailure] Failure result
|
|
118
212
|
# @return [Array] Rack response array
|
|
119
213
|
def html_auth_error(result)
|
|
120
214
|
# For HTML requests, redirect to login
|
|
121
215
|
login_path = auth_config[:login_path] || '/signin'
|
|
122
216
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
217
|
+
headers = { 'location' => login_path }
|
|
218
|
+
|
|
219
|
+
# Add security headers if available
|
|
220
|
+
merge_security_headers!(headers)
|
|
221
|
+
|
|
222
|
+
[302, headers, ["Redirecting to #{login_path}"]]
|
|
128
223
|
end
|
|
129
224
|
|
|
130
225
|
# Generate generic unauthorized response
|
|
@@ -138,11 +233,27 @@ class Otto
|
|
|
138
233
|
|
|
139
234
|
if wants_json
|
|
140
235
|
body = { error: message }.to_json
|
|
141
|
-
|
|
236
|
+
headers = {
|
|
237
|
+
'content-type' => 'application/json',
|
|
238
|
+
'content-length' => body.bytesize.to_s
|
|
239
|
+
}
|
|
240
|
+
merge_security_headers!(headers)
|
|
241
|
+
[401, headers, [body]]
|
|
142
242
|
else
|
|
143
|
-
|
|
243
|
+
headers = { 'content-type' => 'text/plain' }
|
|
244
|
+
merge_security_headers!(headers)
|
|
245
|
+
[401, headers, [message]]
|
|
144
246
|
end
|
|
145
247
|
end
|
|
248
|
+
|
|
249
|
+
# Merge security headers into response headers
|
|
250
|
+
#
|
|
251
|
+
# @param headers [Hash] Response headers hash to merge into
|
|
252
|
+
def merge_security_headers!(headers)
|
|
253
|
+
return unless security_config
|
|
254
|
+
|
|
255
|
+
headers.merge!(security_config.security_headers)
|
|
256
|
+
end
|
|
146
257
|
end
|
|
147
258
|
end
|
|
148
259
|
end
|
|
@@ -10,7 +10,11 @@ class Otto
|
|
|
10
10
|
# Public access strategy - always allows access
|
|
11
11
|
class NoAuthStrategy < AuthStrategy
|
|
12
12
|
def authenticate(env, _requirement)
|
|
13
|
-
|
|
13
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
14
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
15
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
16
|
+
|
|
17
|
+
Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
require_relative 'authentication/auth_strategy'
|
|
9
9
|
require_relative 'authentication/strategy_result'
|
|
10
|
-
require_relative 'authentication/
|
|
11
|
-
require_relative 'authentication/
|
|
10
|
+
require_relative 'authentication/auth_failure'
|
|
11
|
+
require_relative 'authentication/route_auth_wrapper'
|
|
12
12
|
|
|
13
13
|
# Load all strategies
|
|
14
14
|
require_relative 'authentication/strategies/noauth_strategy'
|
|
@@ -26,10 +26,9 @@ class Otto
|
|
|
26
26
|
RoleStrategy = Authentication::Strategies::RoleStrategy
|
|
27
27
|
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
|
28
28
|
PermissionStrategy = Authentication::Strategies::PermissionStrategy
|
|
29
|
-
AuthenticationMiddleware = Authentication::AuthenticationMiddleware
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Top-level backward compatibility aliases
|
|
33
32
|
StrategyResult = Security::Authentication::StrategyResult
|
|
34
|
-
|
|
33
|
+
AuthFailure = Security::Authentication::AuthFailure
|
|
35
34
|
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'digest'
|
|
7
|
+
require_relative '../core/freezable'
|
|
7
8
|
|
|
8
9
|
class Otto
|
|
9
10
|
module Security
|
|
@@ -23,12 +24,15 @@ class Otto
|
|
|
23
24
|
# config.max_request_size = 5 * 1024 * 1024 # 5MB
|
|
24
25
|
# config.max_param_depth = 16
|
|
25
26
|
class Config
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
include Otto::Core::Freezable
|
|
28
|
+
|
|
29
|
+
attr_accessor :input_validation, :max_param_depth, :csrf_token_key, :rate_limiting_config, :csrf_session_key, :max_request_size, :max_param_keys
|
|
30
|
+
|
|
31
|
+
attr_reader :csrf_protection, :csrf_header_key,
|
|
32
|
+
:trusted_proxies, :require_secure_cookies,
|
|
33
|
+
:security_headers,
|
|
34
|
+
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
35
|
+
:ip_privacy_config
|
|
32
36
|
|
|
33
37
|
# Initialize security configuration with safe defaults
|
|
34
38
|
#
|
|
@@ -48,7 +52,8 @@ class Otto
|
|
|
48
52
|
@input_validation = true
|
|
49
53
|
@csp_nonce_enabled = false
|
|
50
54
|
@debug_csp = false
|
|
51
|
-
@rate_limiting_config = {}
|
|
55
|
+
@rate_limiting_config = { custom_rules: {} }
|
|
56
|
+
@ip_privacy_config = Otto::Privacy::Config.new
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
# Enable CSRF (Cross-Site Request Forgery) protection
|
|
@@ -60,14 +65,20 @@ class Otto
|
|
|
60
65
|
# - Provide helper methods for forms and AJAX requests
|
|
61
66
|
#
|
|
62
67
|
# @return [void]
|
|
68
|
+
# @raise [FrozenError] if configuration is frozen
|
|
63
69
|
def enable_csrf_protection!
|
|
70
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
71
|
+
|
|
64
72
|
@csrf_protection = true
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
# Disable CSRF protection
|
|
68
76
|
#
|
|
69
77
|
# @return [void]
|
|
78
|
+
# @raise [FrozenError] if configuration is frozen
|
|
70
79
|
def disable_csrf_protection!
|
|
80
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
81
|
+
|
|
71
82
|
@csrf_protection = false
|
|
72
83
|
end
|
|
73
84
|
|
|
@@ -86,6 +97,7 @@ class Otto
|
|
|
86
97
|
#
|
|
87
98
|
# @param proxy [String, Array] IP address, CIDR range, or array of addresses
|
|
88
99
|
# @raise [ArgumentError] if proxy is not a String or Array
|
|
100
|
+
# @raise [FrozenError] if configuration is frozen
|
|
89
101
|
# @return [void]
|
|
90
102
|
#
|
|
91
103
|
# @example Add single proxy
|
|
@@ -97,6 +109,8 @@ class Otto
|
|
|
97
109
|
# @example Add multiple proxies
|
|
98
110
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
|
99
111
|
def add_trusted_proxy(proxy)
|
|
112
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
113
|
+
|
|
100
114
|
case proxy
|
|
101
115
|
when String, Regexp
|
|
102
116
|
@trusted_proxies << proxy
|
|
@@ -175,7 +189,10 @@ class Otto
|
|
|
175
189
|
# @param max_age [Integer] Maximum age in seconds (default: 1 year)
|
|
176
190
|
# @param include_subdomains [Boolean] Apply to all subdomains (default: true)
|
|
177
191
|
# @return [void]
|
|
192
|
+
# @raise [FrozenError] if configuration is frozen
|
|
178
193
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
194
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
195
|
+
|
|
179
196
|
hsts_value = "max-age=#{max_age}"
|
|
180
197
|
hsts_value += '; includeSubDomains' if include_subdomains
|
|
181
198
|
@security_headers['strict-transport-security'] = hsts_value
|
|
@@ -188,10 +205,13 @@ class Otto
|
|
|
188
205
|
#
|
|
189
206
|
# @param policy [String] CSP policy string (default: "default-src 'self'")
|
|
190
207
|
# @return [void]
|
|
208
|
+
# @raise [FrozenError] if configuration is frozen
|
|
191
209
|
#
|
|
192
210
|
# @example Custom policy
|
|
193
211
|
# config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
194
212
|
def enable_csp!(policy = "default-src 'self'")
|
|
213
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
214
|
+
|
|
195
215
|
@security_headers['content-security-policy'] = policy
|
|
196
216
|
end
|
|
197
217
|
|
|
@@ -203,10 +223,13 @@ class Otto
|
|
|
203
223
|
#
|
|
204
224
|
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
|
205
225
|
# @return [void]
|
|
226
|
+
# @raise [FrozenError] if configuration is frozen
|
|
206
227
|
#
|
|
207
228
|
# @example
|
|
208
229
|
# config.enable_csp_with_nonce!(debug: true)
|
|
209
230
|
def enable_csp_with_nonce!(debug: false)
|
|
231
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
232
|
+
|
|
210
233
|
@csp_nonce_enabled = true
|
|
211
234
|
@debug_csp = debug
|
|
212
235
|
end
|
|
@@ -214,7 +237,10 @@ class Otto
|
|
|
214
237
|
# Disable CSP nonce support
|
|
215
238
|
#
|
|
216
239
|
# @return [void]
|
|
240
|
+
# @raise [FrozenError] if configuration is frozen
|
|
217
241
|
def disable_csp_nonce!
|
|
242
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
243
|
+
|
|
218
244
|
@csp_nonce_enabled = false
|
|
219
245
|
end
|
|
220
246
|
|
|
@@ -246,7 +272,10 @@ class Otto
|
|
|
246
272
|
#
|
|
247
273
|
# @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
|
|
248
274
|
# @return [void]
|
|
275
|
+
# @raise [FrozenError] if configuration is frozen
|
|
249
276
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
277
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
278
|
+
|
|
250
279
|
@security_headers['x-frame-options'] = option
|
|
251
280
|
end
|
|
252
281
|
|
|
@@ -254,6 +283,7 @@ class Otto
|
|
|
254
283
|
#
|
|
255
284
|
# @param headers [Hash] Hash of header name => value pairs
|
|
256
285
|
# @return [void]
|
|
286
|
+
# @raise [FrozenError] if configuration is frozen
|
|
257
287
|
#
|
|
258
288
|
# @example
|
|
259
289
|
# config.set_custom_headers({
|
|
@@ -261,9 +291,23 @@ class Otto
|
|
|
261
291
|
# 'cross-origin-opener-policy' => 'same-origin'
|
|
262
292
|
# })
|
|
263
293
|
def set_custom_headers(headers)
|
|
294
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
295
|
+
|
|
264
296
|
@security_headers.merge!(headers)
|
|
265
297
|
end
|
|
266
298
|
|
|
299
|
+
# Override deep_freeze! to ensure rate_limiting_config has custom_rules initialized
|
|
300
|
+
#
|
|
301
|
+
# This pre-initializes any lazy values before freezing to prevent FrozenError
|
|
302
|
+
# when accessing configuration after it's frozen.
|
|
303
|
+
#
|
|
304
|
+
# @return [self] The frozen configuration
|
|
305
|
+
def deep_freeze!
|
|
306
|
+
# Ensure custom_rules is initialized (should already be done in constructor)
|
|
307
|
+
@rate_limiting_config[:custom_rules] ||= {}
|
|
308
|
+
super
|
|
309
|
+
end
|
|
310
|
+
|
|
267
311
|
def get_or_create_session_id(request)
|
|
268
312
|
# Try existing sources first
|
|
269
313
|
session_id = extract_existing_session_id(request)
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
require_relative 'middleware/csrf_middleware'
|
|
6
6
|
require_relative 'middleware/validation_middleware'
|
|
7
|
-
require_relative 'authentication/authentication_middleware'
|
|
8
7
|
require_relative 'middleware/rate_limit_middleware'
|
|
9
8
|
|
|
10
9
|
# Security configuration facade for Otto framework
|
|
@@ -75,7 +74,6 @@ class Otto
|
|
|
75
74
|
enable_hsts! if hsts
|
|
76
75
|
enable_csp! if csp
|
|
77
76
|
enable_frame_protection! if frame_protection
|
|
78
|
-
enable_authentication! if authentication
|
|
79
77
|
end
|
|
80
78
|
|
|
81
79
|
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
|
@@ -118,7 +116,6 @@ class Otto
|
|
|
118
116
|
# @option options [Integer] :period Time period in seconds (default: 60)
|
|
119
117
|
# @option options [Proc] :condition Optional condition proc that receives request
|
|
120
118
|
def add_rate_limit_rule(name, options)
|
|
121
|
-
@security_config.rate_limiting_config[:custom_rules] ||= {}
|
|
122
119
|
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
|
123
120
|
end
|
|
124
121
|
|
|
@@ -171,21 +168,12 @@ class Otto
|
|
|
171
168
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
172
169
|
end
|
|
173
170
|
|
|
174
|
-
# Enable authentication middleware for route-level access control.
|
|
175
|
-
# This will automatically check route auth parameters and enforce authentication.
|
|
176
|
-
def enable_authentication!
|
|
177
|
-
return if middleware_enabled?(Otto::Security::Authentication::AuthenticationMiddleware)
|
|
178
|
-
|
|
179
|
-
@middleware_stack.add(Otto::Security::Authentication::AuthenticationMiddleware, @auth_config)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
171
|
# Add a single authentication strategy
|
|
183
172
|
#
|
|
184
173
|
# @param name [String] Strategy name
|
|
185
174
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
186
175
|
def add_auth_strategy(name, strategy)
|
|
187
176
|
@auth_config[:auth_strategies][name] = strategy
|
|
188
|
-
enable_authentication!
|
|
189
177
|
end
|
|
190
178
|
|
|
191
179
|
# Configure authentication strategies for route-level access control.
|
|
@@ -196,7 +184,6 @@ class Otto
|
|
|
196
184
|
# Merge new strategies with existing ones, preserving shared state
|
|
197
185
|
@auth_config[:auth_strategies].merge!(strategies)
|
|
198
186
|
@auth_config[:default_auth_strategy] = default_strategy
|
|
199
|
-
enable_authentication! unless strategies.empty?
|
|
200
187
|
end
|
|
201
188
|
|
|
202
189
|
# Configure rate limiting settings.
|